From 930d6690276ebc9a7aec44a675edccee5775a6c4 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 25 Feb 2026 16:04:41 -0800 Subject: [PATCH 01/20] modify gitignore --- core/.gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/.gitignore b/core/.gitignore index 1fe9a12f..18b6c1e3 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -1,3 +1,7 @@ launchSettings.json appsettings.Development.json -*.runsettings \ No newline at end of file +*.runsettings + +# Web build output and dependencies +**/Web/bin/ +**/Web/node_modules/ \ No newline at end of file From e11fd28a7c77e92dda55491cbd32729f3c864195 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Wed, 25 Feb 2026 18:30:06 -0800 Subject: [PATCH 02/20] add sample --- core/core.slnx | 1 + core/samples/TabApp/Body.cs | 11 + core/samples/TabApp/Program.cs | 35 + core/samples/TabApp/README.md | 105 ++ core/samples/TabApp/TabApp.csproj | 18 + core/samples/TabApp/Web/index.html | 12 + core/samples/TabApp/Web/package-lock.json | 1897 +++++++++++++++++++++ core/samples/TabApp/Web/package.json | 22 + core/samples/TabApp/Web/src/App.css | 161 ++ core/samples/TabApp/Web/src/App.tsx | 188 ++ core/samples/TabApp/Web/src/main.tsx | 10 + core/samples/TabApp/Web/tsconfig.json | 21 + core/samples/TabApp/Web/vite.config.ts | 12 + core/samples/TabApp/appsettings.json | 9 + 14 files changed, 2502 insertions(+) create mode 100644 core/samples/TabApp/Body.cs create mode 100644 core/samples/TabApp/Program.cs create mode 100644 core/samples/TabApp/README.md create mode 100644 core/samples/TabApp/TabApp.csproj create mode 100644 core/samples/TabApp/Web/index.html create mode 100644 core/samples/TabApp/Web/package-lock.json create mode 100644 core/samples/TabApp/Web/package.json create mode 100644 core/samples/TabApp/Web/src/App.css create mode 100644 core/samples/TabApp/Web/src/App.tsx create mode 100644 core/samples/TabApp/Web/src/main.tsx create mode 100644 core/samples/TabApp/Web/tsconfig.json create mode 100644 core/samples/TabApp/Web/vite.config.ts create mode 100644 core/samples/TabApp/appsettings.json diff --git a/core/core.slnx b/core/core.slnx index cd660185..aebfd3b9 100644 --- a/core/core.slnx +++ b/core/core.slnx @@ -18,6 +18,7 @@ + diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs new file mode 100644 index 00000000..ba8d3ffe --- /dev/null +++ b/core/samples/TabApp/Body.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; + +namespace TabApp; + +public class PostToChatBody +{ + public required string Message { get; set; } +} diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs new file mode 100644 index 00000000..8ce21664 --- /dev/null +++ b/core/samples/TabApp/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps; +using Microsoft.Teams.Bot.Apps.Handlers; +using Microsoft.Teams.Bot.Apps.Schema; +using TabApp; + +var builder = TeamsBotApplication.CreateBuilder(args); + +var app = builder.Build(); + +// Serve the React tab at /tabs/test (build the web app first: cd Web && npm install && npm run build) +app.WithTab("test", "./Web/bin"); + +// ==================== SERVER FUNCTIONS ==================== + +app.WithFunction("post-to-chat", async (ctx, ct) => +{ + ctx.Log.LogInformation("post-to-chat called by {User} with message: {Message}", ctx.UserName, ctx.Data.Message); + await ctx.SendAsync(ctx.Data.Message, ct); + return new { ok = true }; +}); + +// TODO: Once SSO is implemented, review moving who-am-i and toggle-status functions to server side + +// ==================== MESSAGE ==================== +app.OnMessage(async (ctx, ct) => +{ + await ctx.SendActivityAsync( + new MessageActivity("Open the **Tab** tab to interact with the sample."), + ct); +}); + +app.Run(); diff --git a/core/samples/TabApp/README.md b/core/samples/TabApp/README.md new file mode 100644 index 00000000..f3e25c10 --- /dev/null +++ b/core/samples/TabApp/README.md @@ -0,0 +1,105 @@ +# TabApp + +A sample demonstrating a React/Vite tab served by the bot, with server functions and client-side Graph calls. + +| Feature | How it works | +|---|---| +| **Static tab** | Bot serves `Web/bin` via `app.WithTab("test", "./Web/bin")` at `/tabs/test` | +| **Teams Context** | Reads the raw Teams context via the Teams JS SDK | +| **Post to Chat** | Tab calls `POST /functions/post-to-chat` → bot sends a proactive message | +| **Who Am I** | Acquires a Graph token via MSAL and calls `GET /me` | +| **Toggle Presence** | Acquires a Graph token with `Presence.ReadWrite` and calls `POST /me/presence/setUserPreferredPresence` | + +--- + +## Azure App Registration + +### 1. Application ID URI + +Under **Expose an API → Application ID URI**, set it to: + +``` +api://{YOUR_DOMAIN}/{YOUR_CLIENT_ID} +``` + +Then add a scope named `access_as_user` and pre-authorize the Teams client IDs: + +| Client ID | App | +|---|---| +| `1fec8e78-bce4-4aaf-ab1b-5451cc387264` | Teams desktop / mobile | +| `5e3ce6c0-2b1f-4285-8d4b-75ee78787346` | Teams web | + +### 2. Redirect URI + +Under **Authentication → Add a platform → Single-page application**, add: + +``` +https://{YOUR_DOMAIN}/tabs/test +``` + +### 3. API permissions + +Under **API permissions → Add a permission → Microsoft Graph → Delegated**: + +| Permission | Required for | +|---|---| +| `User.Read` | Who Am I | +| `Presence.ReadWrite` | Toggle Presence | + +--- + +## Manifest + +**`webApplicationInfo`** — required for SSO (`authentication.getAuthToken()` and MSAL silent auth): + +```json +"webApplicationInfo": { + "id": "{YOUR_CLIENT_ID}", + "resource": "api://{YOUR_DOMAIN}/{YOUR_CLIENT_ID}" +} +``` + +**`staticTabs`**: + +```json +"staticTabs": [ + { + "entityId": "tab", + "name": "Tab", + "contentUrl": "https://{YOUR_DOMAIN}/tabs/test", + "websiteUrl": "https://{YOUR_DOMAIN}/tabs/test", + "scopes": ["personal"] + } +] +``` + +--- + +## Configuration + +**`launchSettings.json`** (or environment variables): + +```json +"AzureAD__TenantId": "{YOUR_TENANT_ID}", +"AzureAD__ClientId": "{YOUR_CLIENT_ID}", +"AzureAD__ClientCredentials__0__SourceType": "ClientSecret", +"AzureAd__ClientCredentials__0__ClientSecret": "{YOUR_CLIENT_SECRET}" +``` + +**`Web/.env.local`**: + +``` +VITE_CLIENT_ID={YOUR_CLIENT_ID} +``` + +--- + +## Build & Run + +```bash +# Build the React app +cd Web && npm install && npm run build + +# Run the bot +dotnet run +``` diff --git a/core/samples/TabApp/TabApp.csproj b/core/samples/TabApp/TabApp.csproj new file mode 100644 index 00000000..05793d5c --- /dev/null +++ b/core/samples/TabApp/TabApp.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/core/samples/TabApp/Web/index.html b/core/samples/TabApp/Web/index.html new file mode 100644 index 00000000..192d27c2 --- /dev/null +++ b/core/samples/TabApp/Web/index.html @@ -0,0 +1,12 @@ + + + + + + Teams Tab + + +
+ + + diff --git a/core/samples/TabApp/Web/package-lock.json b/core/samples/TabApp/Web/package-lock.json new file mode 100644 index 00000000..4a47acc8 --- /dev/null +++ b/core/samples/TabApp/Web/package-lock.json @@ -0,0 +1,1897 @@ +{ + "name": "tabsapp-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tabsapp-web", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/teams-js": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@microsoft/teams-js/-/teams-js-2.48.1.tgz", + "integrity": "sha512-zL+DzftBSfLnC2r8MK3DdzQBxsbCQcxvHpTO+AkSpxNQw+UD/bpEA1mzhs2r3fqjocjlOLWsSjY8yveNLPUEEA==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "debug": "^4.3.3" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/core/samples/TabApp/Web/package.json b/core/samples/TabApp/Web/package.json new file mode 100644 index 00000000..66ecefaa --- /dev/null +++ b/core/samples/TabApp/Web/package.json @@ -0,0 +1,22 @@ +{ + "name": "tabsapp-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "@azure/msal-browser": "^3.0.0", + "@microsoft/teams-js": "^2.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/core/samples/TabApp/Web/src/App.css b/core/samples/TabApp/Web/src/App.css new file mode 100644 index 00000000..ec308897 --- /dev/null +++ b/core/samples/TabApp/Web/src/App.css @@ -0,0 +1,161 @@ +/* ─── Design tokens ──────────────────────────────────────────────────────── */ +:root { + --bg: #f5f5f5; /* page background */ + --surface: #ffffff; /* card / elevated surface */ + --text: #111111; + --accent: #6264a7; /* Teams purple */ + --accent-hover: #4f50a0; + --border: #e0e0e0; + --hint: #666; /* secondary / helper text */ +} + +/* ─── OS-level dark mode ─────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; + } +} + +/* ─── Teams theme override ───────────────────────────────────────────────── + Teams injects a data-theme attribute on ("default", "dark", + "contrast"). */ +[data-theme='dark'], +[data-theme='contrast'] { + --bg: #1a1a1a; + --surface: #2d2d2d; + --text: #f0f0f0; + --accent: #9ea5ff; + --accent-hover: #b8bdff; + --border: #444; + --hint: #aaa; +} + +/* ─── Reset ──────────────────────────────────────────────────────────────── */ +* { + box-sizing: border-box; +} + +/* ─── Base ───────────────────────────────────────────────────────────────── + Segoe UI is the Teams typeface; the stack falls back gracefully on other + platforms. */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +/* ─── Page layout ────────────────────────────────────────────────────────── + Constrain content width and centre it so the tab reads well on wide + desktop clients. */ +.app { + max-width: 680px; + margin: 0 auto; + padding: 24px 16px; +} + +h1 { + font-size: 1.4rem; + color: var(--accent); + margin: 0 0 20px; +} + +/* ─── Card ───────────────────────────────────────────────────────────────── + Each functional section (post-to-chat, who-am-i, …) lives in a card so + they're visually separated without hard dividers. */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 14px; +} + +.card h2 { + margin: 0 0 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ─── Helper text ────────────────────────────────────────────────────────── + Short description shown below a card heading. */ +.hint { + margin: 0 0 12px; + font-size: 0.82rem; + color: var(--hint); +} + +/* ─── JSON output ────────────────────────────────────────────────────────── + Used inside .result cards to display raw server responses. */ +pre { + background: var(--bg); + border-radius: 4px; + padding: 10px; + font-size: 0.8rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +/* ─── Text input ─────────────────────────────────────────────────────────── + Full-width so it stays aligned with the button below it. */ +input { + display: block; + width: 100%; + padding: 8px 10px; + margin-bottom: 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.9rem; +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ─── Button ─────────────────────────────────────────────────────────────── + Teams-purple fill; transitions smoothly on hover. */ +button { + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + padding: 8px 18px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: background 0.15s; +} + +button:hover { + background: var(--accent-hover); +} + +/* ─── Loading state ──────────────────────────────────────────────────────── + Shown while Teams SDK initialises (before app.initialize() resolves). */ +.loading { + padding: 60px; + text-align: center; + color: var(--hint); +} + +/* ─── Result card ────────────────────────────────────────────────────────── + Accent-coloured border makes the response stand out from regular cards. */ +.result pre { + border: 1px solid var(--accent); +} diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx new file mode 100644 index 00000000..51bae36c --- /dev/null +++ b/core/samples/TabApp/Web/src/App.tsx @@ -0,0 +1,188 @@ +import { useState, useEffect, useCallback } from 'react' +import { app, authentication } from '@microsoft/teams-js' +import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser' + +let _msal: PublicClientApplication | null = null + +async function getMsal(tenantId: string): Promise { + if (!_msal) { + _msal = new PublicClientApplication({ + auth: { + clientId: import.meta.env.VITE_CLIENT_ID as string, + authority: `https://login.microsoftonline.com/${tenantId}`, + redirectUri: window.location.origin + window.location.pathname, + }, + }) + await _msal.initialize() + } + return _msal +} + +export default function App() { + const [context, setContext] = useState(null) + const [message, setMessage] = useState('Hello from the tab!') + const [result, setResult] = useState('') + const [initialized, setInitialized] = useState(false) + const [status, setStatus] = useState(false) + + useEffect(() => { + app.initialize().then(() => { + app.getContext().then((ctx) => { + setContext(ctx) + setInitialized(true) + }) + }) + }, []) + + async function callFunction(name: string, body: unknown): Promise { + const [token, ctx] = await Promise.all([ + authentication.getAuthToken(), + app.getContext(), + ]) + + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + } + + if (ctx.app.sessionId) headers['X-Teams-App-Session-Id'] = ctx.app.sessionId + if (ctx.page.id) headers['X-Teams-Page-Id'] = ctx.page.id + if (ctx.page.subPageId) headers['X-Teams-Sub-Page-Id'] = ctx.page.subPageId + if (ctx.channel?.id) headers['X-Teams-Channel-Id'] = ctx.channel.id + if (ctx.chat?.id) headers['X-Teams-Chat-Id'] = ctx.chat.id + if (ctx.meeting?.id) headers['X-Teams-Meeting-Id'] = ctx.meeting.id + if (ctx.team?.groupId) headers['X-Teams-Team-Id'] = ctx.team.groupId + + const res = await fetch(`/functions/${name}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + } + + async function run(fn: () => Promise) { + try { + const res = await fn() + setResult(JSON.stringify(res, null, 2)) + } catch (e) { + setResult(String(e)) + } + } + + const showContext = useCallback(() => run(async () => context), [context]) + const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message })), [message]) + const whoAmI = useCallback(() => run(async () => { + const tenantId = context?.user?.tenant?.id ?? 'common' + const loginHint = context?.user?.loginHint + const msal = await getMsal(tenantId) + const scopes = ['User.Read'] + + const accounts = msal.getAllAccounts() + const account = loginHint + ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) + : accounts[0] + + let accessToken: string + try { + if (!account) throw new InteractionRequiredAuthError('no_account') + const result = await msal.acquireTokenSilent({ scopes, account }) + accessToken = result.accessToken + } catch (e) { + if (!(e instanceof InteractionRequiredAuthError)) throw e + const result = await msal.acquireTokenPopup({ scopes, loginHint }) + accessToken = result.accessToken + } + + return fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(r => r.json()) + }), [context]) + + // TODO: Move whoAmI and toggleStatus to server-side bot functions once SSO OBO is implemented, + // so Graph token acquisition happens on the server via the On-Behalf-Of flow. + const toggleStatus = useCallback(() => run(async () => { + const tenantId = context?.user?.tenant?.id ?? 'common' + const loginHint = context?.user?.loginHint + const msal = await getMsal(tenantId) + const scopes = ['Presence.ReadWrite'] + + const accounts = msal.getAllAccounts() + const account = loginHint + ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) + : accounts[0] + + let accessToken: string + try { + if (!account) throw new InteractionRequiredAuthError('no_account') + const result = await msal.acquireTokenSilent({ scopes, account }) + accessToken = result.accessToken + } catch (e) { + if (!(e instanceof InteractionRequiredAuthError)) throw e + const result = await msal.acquireTokenPopup({ scopes, loginHint }) + accessToken = result.accessToken + } + + const newStatus = !status + const availability = newStatus ? 'DoNotDisturb' : 'Available' + + const res = await fetch('https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence', { + method: 'POST', + headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ availability, activity: availability }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(`Graph ${res.status}: ${JSON.stringify(body)}`) + } + setStatus(newStatus) + return { availability } + }), [status, context]) + + if (!initialized) { + return
Initializing Teams SDK…
+ } + + return ( +
+

Teams Tab Sample

+ +
+

Teams Context

+

Shows the raw Teams context for this session.

+ +
+ +
+

Post to Chat

+

Sends a proactive message via the bot.

+ setMessage(e.target.value)} + placeholder="Message text" + /> + +
+ +
+

Who Am I

+

Looks up your member record.

+ +
+ +
+

Toggle Presence

+

Sets your Teams presence via Graph. Current: {status ? 'DoNotDisturb' : 'Available'}

+ +
+ + {result && ( +
+

Result

+
{result}
+
+ )} +
+ ) +} diff --git a/core/samples/TabApp/Web/src/main.tsx b/core/samples/TabApp/Web/src/main.tsx new file mode 100644 index 00000000..df579560 --- /dev/null +++ b/core/samples/TabApp/Web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/core/samples/TabApp/Web/tsconfig.json b/core/samples/TabApp/Web/tsconfig.json new file mode 100644 index 00000000..21cb8814 --- /dev/null +++ b/core/samples/TabApp/Web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/core/samples/TabApp/Web/vite.config.ts b/core/samples/TabApp/Web/vite.config.ts new file mode 100644 index 00000000..3ed0f3db --- /dev/null +++ b/core/samples/TabApp/Web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + // Must match the tab name passed to app.WithTab("test", ...) + base: '/tabs/test', + build: { + outDir: 'bin', + emptyOutDir: true, + }, +}) diff --git a/core/samples/TabApp/appsettings.json b/core/samples/TabApp/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/TabApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} From 827cc798e7407778b97363bfca7e8bc068ac0982 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 11:25:45 -0800 Subject: [PATCH 03/20] entra auth --- .../Hosting/JwtExtensions.cs | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index da77f8ea..0471eabd 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -23,6 +23,8 @@ public static class JwtExtensions { internal const string BotScheme = "BotScheme"; internal const string AgentScheme = "AgentScheme"; + internal const string EntraScheme = "EntraScheme"; + internal const string EntraPolicy = "EntraPolicy"; internal const string BotScope = "https://api.botframework.com/.default"; internal const string AgentScope = "https://botapi.skype.com/.default"; internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; @@ -44,10 +46,16 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection AuthenticationBuilder builder = services.AddAuthentication(); ArgumentNullException.ThrowIfNull(configuration); + string audience = configuration[$"{aadSectionName}:ClientId"] - ?? configuration["CLIENT_ID"] - ?? configuration["MicrosoftAppId"] - ?? throw new InvalidOperationException("ClientID not found in configuration, tried the 3 option"); + ?? configuration["CLIENT_ID"] + ?? configuration["MicrosoftAppId"] + ?? throw new InvalidOperationException("ClientID not found in configuration, tried the 3 option"); + + string tenantId = configuration[$"{aadSectionName}:TenantId"] + ?? configuration["TENANT_ID"] + ?? configuration["MicrosoftAppTenantId"] + ?? string.Empty; if (!useAgentAuth) { @@ -56,14 +64,17 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection } else { - string tenantId = configuration[$"{aadSectionName}:TenantId"] - ?? configuration["TENANT_ID"] - ?? configuration["MicrosoftAppTenantId"] - ?? "botframework.com"; // TODO: Task 5039198: Test JWT Validation for MultiTenant - - string[] validIssuers = [$"https://sts.windows.net/{tenantId}/", $"https://login.microsoftonline.com/{tenantId}/v2", "https://api.botframework.com"]; + string agentTenantId = string.IsNullOrEmpty(tenantId) ? "botframework.com" : tenantId; // TODO: Task 5039198: Test JWT Validation for MultiTenant + string[] validIssuers = [$"https://sts.windows.net/{agentTenantId}/", $"https://login.microsoftonline.com/{agentTenantId}/v2", "https://api.botframework.com"]; builder.AddCustomJwtBearer(AgentScheme, validIssuers, audience, logger); } + + // Register the Entra user token scheme for tab function endpoints. + string[] entraIssuers = string.IsNullOrEmpty(tenantId) + ? ["https://login.microsoftonline.com/common/v2.0"] + : [$"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/"]; + builder.AddCustomJwtBearer(EntraScheme, entraIssuers, audience, logger); + return builder; } @@ -100,6 +111,7 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv bool useAgentAuth = string.Equals(azureScope, AgentScope, StringComparison.OrdinalIgnoreCase); services.AddBotAuthentication(configuration, useAgentAuth, logger, aadSectionName); + AuthorizationBuilder authorizationBuilder = services .AddAuthorizationBuilder() .AddDefaultPolicy(aadSectionName, policy => @@ -113,6 +125,11 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv policy.AuthenticationSchemes.Add(AgentScheme); } policy.RequireAuthenticatedUser(); + }) + .AddPolicy(EntraPolicy, policy => + { + policy.AddAuthenticationSchemes(EntraScheme); + policy.RequireAuthenticatedUser(); }); return authorizationBuilder; } From 2530afa51d2fbfec335900f43f29b3fd98723b92 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 11:59:01 -0800 Subject: [PATCH 04/20] add tabs and fucntions --- .../FunctionContext.cs | 107 +++++++++++ .../TeamsBotApplication.cs | 168 +++++++++++++++++- 2 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs new file mode 100644 index 00000000..9fbe3a33 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps; + +/// +/// Context passed to a server function handler registered via +/// (no request body). +/// +public class FunctionContext(TeamsBotApplication botApp, ILogger log) +{ + /// Gets or sets the bot's application (client) ID./>. + public string? BotId { get; set; } + + /// Gets or sets the Teams bot service URL for proactive messaging. + public Uri? ServiceUrl { get; set; } + + /// Gets or sets the Microsoft Entra tenant ID, extracted from the request auth token. + public string? TenantId { get; set; } + + /// Gets or sets the Microsoft Entra object ID of the current user, extracted from the request auth token. + public string? UserId { get; set; } + + /// Gets or sets the name of the current user, extracted from the request auth token. + public string? UserName { get; set; } + + /// Gets or sets the MSAL Entra auth token from the request Authorization header. + public string? AuthToken { get; set; } + + /// Gets or sets the unique ID for the current app session (X-Teams-App-Session-Id header). + public string? AppSessionId { get; set; } + + /// Gets or sets the developer-defined unique ID for the page (X-Teams-Page-Id header). + public string? PageId { get; set; } + + /// Gets or sets the developer-defined unique ID for the sub-page (X-Teams-Sub-Page-Id header). + public string? SubPageId { get; set; } + + /// Gets or sets the Microsoft Teams channel ID (X-Teams-Channel-Id header). + public string? ChannelId { get; set; } + + /// Gets or sets the Microsoft Teams chat ID (X-Teams-Chat-Id header). + public string? ChatId { get; set; } + + /// Gets or sets the Microsoft Teams meeting ID (X-Teams-Meeting-Id header). + public string? MeetingId { get; set; } + + /// Gets or sets the Microsoft Teams team ID (X-Teams-Team-Id header). + public string? TeamId { get; set; } + + /// Gets or sets the ID of the parent message from which a task module was launched (X-Teams-Message-Id header). + public string? MessageId { get; set; } + + /// + /// Gets the Teams conversation ID. Resolved from or . + /// + public string? ConversationId => ChatId ?? ChannelId; + + /// Gets the logger for this function. + public ILogger Log { get; } = log; + + /// + /// Sends a text message to the conversation proactively. + /// + public async Task SendAsync(string message, CancellationToken cancellationToken = default) + { + var conversationId = ConversationId; + + // Conversation ID can be missing if the app is running in a personal scope. In this case, create + // a conversation between the bot and the user. This will either create a new conversation or return + // a pre-existing one. + if (conversationId is null) + { + var res = await botApp.ConversationClient.CreateConversationAsync(new ConversationParameters + { + TenantId = TenantId, + IsGroup = false, + Bot = new ConversationAccount { Id = BotId }, + Members = [new ConversationAccount { Id = UserId }] + }, ServiceUrl!, cancellationToken: cancellationToken).ConfigureAwait(false); + + conversationId = res.Id; + } + + var activity = new MessageActivity(message) { ServiceUrl = ServiceUrl }; + activity.Conversation.Id = conversationId!; + + return await botApp.ConversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); + } +} + +/// +/// Context passed to a server function handler registered via +/// . +/// The deserialized request body is available via . +/// +public class FunctionContext(TeamsBotApplication botApp, ILogger log, T data) + : FunctionContext(botApp, log) +{ + /// Gets the deserialized request body. + public T Data { get; } = data; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index 19ebcb22..13107d53 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.Teams.Bot.Core; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Apps.Routing; -using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; namespace Microsoft.Teams.Bot.Apps; @@ -18,6 +23,7 @@ public class TeamsBotApplication : BotApplication { private readonly TeamsApiClient _teamsApiClient; private static TeamsBotApplicationBuilder? _botApplicationBuilder; + private static readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); /// /// Gets the router for dispatching Teams activities to registered routes. @@ -29,6 +35,9 @@ public class TeamsBotApplication : BotApplication /// public TeamsApiClient TeamsApiClient => _teamsApiClient; + private static WebApplication WebApp => _botApplicationBuilder?.WebApplication + ?? throw new InvalidOperationException("Call Build() first."); + /// /// @@ -82,6 +91,131 @@ public static TeamsBotApplicationBuilder CreateBuilder(string[] args) return _botApplicationBuilder; } + /// + /// Registers a tab to be hosted at /tabs/{name}, serving files from the given physical directory. + /// + /// The tab name used in the URL path. + /// Absolute or relative path to the directory containing the tab's static files. + /// The current instance for fluent chaining. + // TODO : breaking change to have withTab instead of AddTab + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Provider is disposed in catch on failure; on success disposal is registered with IHostApplicationLifetime.ApplicationStopped.")] + public TeamsBotApplication + WithTab(string name, string physicalPath) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentException.ThrowIfNullOrEmpty(physicalPath, nameof(physicalPath)); + PhysicalFileProvider provider = new(Path.GetFullPath(physicalPath)); + try + { + WebApp.Services.GetRequiredService() + .ApplicationStopped.Register(provider.Dispose); + return WithTab(name, provider); + } + catch + { + provider.Dispose(); + throw; + } + } + + /// + /// Registers a tab to be hosted at /tabs/{name}, serving files from the given file provider. + /// + /// The tab name used in the URL path. + /// File provider that supplies the tab's static files (e.g. embedded resources). + /// The current instance for fluent chaining. + // TODO : breaking change to have withTab instead of AddTab + public TeamsBotApplication WithTab(string name, IFileProvider provider) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(provider, nameof(provider)); + + WebApp.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = $"/tabs/{name}", + ServeUnknownFileTypes = true + }); + + WebApp.MapGet($"/tabs/{name}", () => + { + IFileInfo file = provider.GetFileInfo("index.html"); + return file.Exists + ? Results.File(file.CreateReadStream(), "text/html") + : Results.NotFound(); + }); + + WebApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => + { + IFileInfo file = provider.GetFileInfo(path); + if (!file.Exists) return Results.NotFound(); + _contentTypeProvider.TryGetContentType(file.Name, out var contentType); + return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); + }); + + return this; + } + + /// + /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body. + /// Client context (tenant, user, conversation) is populated from the validated auth token and + /// X-Teams-* request headers. The deserialized body is available via . + /// + /// The type to deserialize the JSON request body into. + /// The function name used in the URL path. + /// The async handler. Its return value is serialized as the JSON response. + /// The current instance for fluent chaining. + // TODO : breaking change to have withFunction instead of AddFunction + public TeamsBotApplication WithFunction( + string name, + Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + WebApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => + { + + ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); + TBody? body = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false); + FunctionContext ctx = new(this, logger, body!); + PopulateClientContext(ctx, httpCtx); + var result = await handler(ctx, ct).ConfigureAwait(false); + return Results.Json(result); + }).RequireAuthorization(JwtExtensions.EntraPolicy); + + return this; + } + + /// + /// Registers an HTTP POST endpoint at /functions/{name} with no request body. + /// Client context (tenant, user, conversation) is populated from the validated auth token and + /// X-Teams-* request headers. + /// + /// The function name used in the URL path, e.g. "who-am-i". + /// The async handler. Its return value is serialized as the JSON response. + /// The current instance for fluent chaining. + // TODO : breaking change to have withFunction instead of AddFunction + public TeamsBotApplication WithFunction( + string name, + Func> handler) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + WebApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => + { + ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); + FunctionContext ctx = new(this, logger); + PopulateClientContext(ctx, httpCtx); + var result = await handler(ctx, ct).ConfigureAwait(false); + return Results.Json(result); + }).RequireAuthorization(JwtExtensions.EntraPolicy); + + return this; + } + /// /// Runs the web application configured by the bot application builder. /// @@ -96,4 +230,34 @@ public void Run() _botApplicationBuilder.WebApplication.Run(); } + private static void PopulateClientContext(FunctionContext ctx, HttpContext httpCtx) + { + BotApplicationOptions botOptions = httpCtx.RequestServices.GetRequiredService(); + ctx.BotId = botOptions.AppId; + ctx.ServiceUrl = botOptions.ServiceUrl; + + ctx.TenantId = httpCtx.User.FindFirst("tid")?.Value; + ctx.UserId = httpCtx.User.FindFirst("oid")?.Value; + ctx.UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value; + ctx.AuthToken = httpCtx.Request.Headers.Authorization.FirstOrDefault() + ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); + + // X-Teams-* headers sent by the Teams JS client + if (httpCtx.Request.Headers.TryGetValue("X-Teams-App-Session-Id", out var appSessionId)) + ctx.AppSessionId = appSessionId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Page-Id", out var pageId)) + ctx.PageId = pageId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Channel-Id", out var channelId)) + ctx.ChannelId = channelId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Chat-Id", out var chatId)) + ctx.ChatId = chatId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Meeting-Id", out var meetingId)) + ctx.MeetingId = meetingId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Team-Id", out var teamId)) + ctx.TeamId = teamId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Message-Id", out var messageId)) + ctx.MessageId = messageId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Sub-Page-Id", out var subPageId)) + ctx.SubPageId = subPageId; + } } From 7095581f73e4a117efb86d2680389cc76f2657a6 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 11:59:24 -0800 Subject: [PATCH 05/20] add service url as configurable --- .../Hosting/AddBotApplicationExtensions.cs | 4 +++- .../Hosting/BotApplicationOptions.cs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs index df3a3f02..363a5541 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -102,9 +102,11 @@ public static IServiceCollection AddBotApplication(this IServiceCollection services.AddSingleton(sp => { var config = sp.GetRequiredService(); + var serviceUrlStr = config["SERVICE_URL"] ?? "https://smba.trafficmanager.net/teams"; return new BotApplicationOptions { - AppId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? string.Empty + AppId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? string.Empty, + ServiceUrl = new Uri(serviceUrlStr.TrimEnd('/')) }; }); services.AddAuthorization(logger, sectionName); diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs index 5e23f17b..92f395d7 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs @@ -12,4 +12,11 @@ public sealed class BotApplicationOptions /// Gets or sets the application (client) ID, used for logging and diagnostics. /// public string AppId { get; set; } = string.Empty; + + /// + /// Gets or sets the Teams bot service URL used for proactive messaging. + /// Defaults to https://smba.trafficmanager.net/teams if not configured via the + /// SERVICE_URL environment variable or app settings. + /// + public Uri ServiceUrl { get; set; } = new Uri("https://smba.trafficmanager.net/teams"); } From a784340fd8bb9b3dd3d4f2e66e719422c2f964f6 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 13:37:10 -0800 Subject: [PATCH 06/20] move to builder --- core/samples/TabApp/Program.cs | 8 +- .../TeamsBotApplication.cs | 166 --------------- .../TeamsBotApplicationBuilder.cs | 194 +++++++++++++++++- 3 files changed, 195 insertions(+), 173 deletions(-) diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index 8ce21664..f4780006 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -8,14 +8,12 @@ var builder = TeamsBotApplication.CreateBuilder(args); -var app = builder.Build(); - // Serve the React tab at /tabs/test (build the web app first: cd Web && npm install && npm run build) -app.WithTab("test", "./Web/bin"); +builder.WithTab("test", "./Web/bin"); // ==================== SERVER FUNCTIONS ==================== -app.WithFunction("post-to-chat", async (ctx, ct) => +builder.WithFunction("post-to-chat", async (ctx, ct) => { ctx.Log.LogInformation("post-to-chat called by {User} with message: {Message}", ctx.UserName, ctx.Data.Message); await ctx.SendAsync(ctx.Data.Message, ct); @@ -24,6 +22,8 @@ // TODO: Once SSO is implemented, review moving who-am-i and toggle-status functions to server side +var app = builder.Build(); + // ==================== MESSAGE ==================== app.OnMessage(async (ctx, ct) => { diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs index 13107d53..63235092 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplication.cs @@ -1,13 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Security.Claims; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Apps.Routing; using Microsoft.Teams.Bot.Apps.Schema; @@ -23,7 +17,6 @@ public class TeamsBotApplication : BotApplication { private readonly TeamsApiClient _teamsApiClient; private static TeamsBotApplicationBuilder? _botApplicationBuilder; - private static readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); /// /// Gets the router for dispatching Teams activities to registered routes. @@ -35,10 +28,6 @@ public class TeamsBotApplication : BotApplication /// public TeamsApiClient TeamsApiClient => _teamsApiClient; - private static WebApplication WebApp => _botApplicationBuilder?.WebApplication - ?? throw new InvalidOperationException("Call Build() first."); - - /// /// /// @@ -91,131 +80,6 @@ public static TeamsBotApplicationBuilder CreateBuilder(string[] args) return _botApplicationBuilder; } - /// - /// Registers a tab to be hosted at /tabs/{name}, serving files from the given physical directory. - /// - /// The tab name used in the URL path. - /// Absolute or relative path to the directory containing the tab's static files. - /// The current instance for fluent chaining. - // TODO : breaking change to have withTab instead of AddTab - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", - Justification = "Provider is disposed in catch on failure; on success disposal is registered with IHostApplicationLifetime.ApplicationStopped.")] - public TeamsBotApplication - WithTab(string name, string physicalPath) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentException.ThrowIfNullOrEmpty(physicalPath, nameof(physicalPath)); - PhysicalFileProvider provider = new(Path.GetFullPath(physicalPath)); - try - { - WebApp.Services.GetRequiredService() - .ApplicationStopped.Register(provider.Dispose); - return WithTab(name, provider); - } - catch - { - provider.Dispose(); - throw; - } - } - - /// - /// Registers a tab to be hosted at /tabs/{name}, serving files from the given file provider. - /// - /// The tab name used in the URL path. - /// File provider that supplies the tab's static files (e.g. embedded resources). - /// The current instance for fluent chaining. - // TODO : breaking change to have withTab instead of AddTab - public TeamsBotApplication WithTab(string name, IFileProvider provider) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(provider, nameof(provider)); - - WebApp.UseStaticFiles(new StaticFileOptions - { - FileProvider = provider, - RequestPath = $"/tabs/{name}", - ServeUnknownFileTypes = true - }); - - WebApp.MapGet($"/tabs/{name}", () => - { - IFileInfo file = provider.GetFileInfo("index.html"); - return file.Exists - ? Results.File(file.CreateReadStream(), "text/html") - : Results.NotFound(); - }); - - WebApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => - { - IFileInfo file = provider.GetFileInfo(path); - if (!file.Exists) return Results.NotFound(); - _contentTypeProvider.TryGetContentType(file.Name, out var contentType); - return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); - }); - - return this; - } - - /// - /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body. - /// Client context (tenant, user, conversation) is populated from the validated auth token and - /// X-Teams-* request headers. The deserialized body is available via . - /// - /// The type to deserialize the JSON request body into. - /// The function name used in the URL path. - /// The async handler. Its return value is serialized as the JSON response. - /// The current instance for fluent chaining. - // TODO : breaking change to have withFunction instead of AddFunction - public TeamsBotApplication WithFunction( - string name, - Func, CancellationToken, Task> handler) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(handler, nameof(handler)); - - WebApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => - { - - ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); - TBody? body = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false); - FunctionContext ctx = new(this, logger, body!); - PopulateClientContext(ctx, httpCtx); - var result = await handler(ctx, ct).ConfigureAwait(false); - return Results.Json(result); - }).RequireAuthorization(JwtExtensions.EntraPolicy); - - return this; - } - - /// - /// Registers an HTTP POST endpoint at /functions/{name} with no request body. - /// Client context (tenant, user, conversation) is populated from the validated auth token and - /// X-Teams-* request headers. - /// - /// The function name used in the URL path, e.g. "who-am-i". - /// The async handler. Its return value is serialized as the JSON response. - /// The current instance for fluent chaining. - // TODO : breaking change to have withFunction instead of AddFunction - public TeamsBotApplication WithFunction( - string name, - Func> handler) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(handler, nameof(handler)); - - WebApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => - { - ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); - FunctionContext ctx = new(this, logger); - PopulateClientContext(ctx, httpCtx); - var result = await handler(ctx, ct).ConfigureAwait(false); - return Results.Json(result); - }).RequireAuthorization(JwtExtensions.EntraPolicy); - - return this; - } - /// /// Runs the web application configured by the bot application builder. /// @@ -230,34 +94,4 @@ public void Run() _botApplicationBuilder.WebApplication.Run(); } - private static void PopulateClientContext(FunctionContext ctx, HttpContext httpCtx) - { - BotApplicationOptions botOptions = httpCtx.RequestServices.GetRequiredService(); - ctx.BotId = botOptions.AppId; - ctx.ServiceUrl = botOptions.ServiceUrl; - - ctx.TenantId = httpCtx.User.FindFirst("tid")?.Value; - ctx.UserId = httpCtx.User.FindFirst("oid")?.Value; - ctx.UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value; - ctx.AuthToken = httpCtx.Request.Headers.Authorization.FirstOrDefault() - ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); - - // X-Teams-* headers sent by the Teams JS client - if (httpCtx.Request.Headers.TryGetValue("X-Teams-App-Session-Id", out var appSessionId)) - ctx.AppSessionId = appSessionId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Page-Id", out var pageId)) - ctx.PageId = pageId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Channel-Id", out var channelId)) - ctx.ChannelId = channelId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Chat-Id", out var chatId)) - ctx.ChatId = chatId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Meeting-Id", out var meetingId)) - ctx.MeetingId = meetingId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Team-Id", out var teamId)) - ctx.TeamId = teamId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Message-Id", out var messageId)) - ctx.MessageId = messageId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Sub-Page-Id", out var subPageId)) - ctx.SubPageId = subPageId; - } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index 5b212fc6..ba1da9c8 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -1,12 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; namespace Microsoft.Teams.Bot.Apps; @@ -19,27 +23,37 @@ public class TeamsBotApplicationBuilder private readonly WebApplicationBuilder _webAppBuilder; private WebApplication? _webApp; private string _routePath = "/api/messages"; + private readonly List> _tabActions = []; + private readonly List> _functionActions = []; + private static readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + internal WebApplication WebApplication => _webApp ?? throw new InvalidOperationException("Call Build"); + /// /// Accessor for the service collection used to configure application services. /// public IServiceCollection Services => _webAppBuilder.Services; + /// /// Accessor for the application configuration used to configure services and settings. /// public IConfiguration Configuration => _webAppBuilder.Configuration; + /// /// Accessor for the web hosting environment information. /// public IWebHostEnvironment Environment => _webAppBuilder.Environment; + /// /// Accessor for configuring the host settings and services. /// public ConfigureHostBuilder Host => _webAppBuilder.Host; + /// /// Accessor for configuring logging services and settings. /// public ILoggingBuilder Logging => _webAppBuilder.Logging; + /// /// Creates a new instance of the BotApplicationBuilder with default configuration and registered bot services. /// @@ -52,14 +66,21 @@ public TeamsBotApplicationBuilder(string[] args) /// /// Builds and configures the bot application pipeline, returning a fully initialized instance of the bot - /// application. + /// application. All registered tabs and functions are mapped to the web application at this point. /// - /// A configured instance representing the bot application pipeline. + /// A configured instance. public TeamsBotApplication Build() { _webApp = _webAppBuilder.Build(); TeamsBotApplication botApp = _webApp.Services.GetService() ?? throw new InvalidOperationException("Application not registered"); _webApp.UseBotApplication(_routePath); + + foreach (var tabAction in _tabActions) + tabAction(_webApp); + + foreach (var funcAction in _functionActions) + funcAction(_webApp, botApp); + return botApp; } @@ -73,4 +94,171 @@ public TeamsBotApplicationBuilder WithRoutePath(string routePath) _routePath = routePath; return this; } + + /// + /// Registers a tab to be hosted at /tabs/{name}, serving files from the given physical directory. + /// Routes are mapped when is called. + /// + /// The tab name used in the URL path. + /// Absolute or relative path to the directory containing the tab's static files. + /// The current instance for fluent chaining. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Provider is disposed in catch on failure; on success disposal is registered with IHostApplicationLifetime.ApplicationStopped.")] + public TeamsBotApplicationBuilder WithTab(string name, string physicalPath) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentException.ThrowIfNullOrEmpty(physicalPath, nameof(physicalPath)); + + _tabActions.Add(webApp => + { + PhysicalFileProvider provider = new(Path.GetFullPath(physicalPath)); + try + { + webApp.Services.GetRequiredService() + .ApplicationStopped.Register(provider.Dispose); + ApplyTab(webApp, name, provider); + } + catch + { + provider.Dispose(); + throw; + } + }); + + return this; + } + + /// + /// Registers a tab to be hosted at /tabs/{name}, serving files from the given file provider. + /// Routes are mapped when is called. + /// + /// The tab name used in the URL path. + /// File provider that supplies the tab's static files. + /// The current instance for fluent chaining. + public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(provider, nameof(provider)); + + _tabActions.Add(webApp => ApplyTab(webApp, name, provider)); + + return this; + } + + /// + /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body. + /// The endpoint is mapped when is called. + /// + /// The type to deserialize the JSON request body into. + /// The function name used in the URL path. + /// The async handler. Its return value is serialized as the JSON response. + /// The current instance for fluent chaining. + public TeamsBotApplicationBuilder WithFunction( + string name, + Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + _functionActions.Add((webApp, botApp) => + { + webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => + { + ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); + TBody? body = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false); + FunctionContext ctx = new(botApp, logger, body!); + PopulateClientContext(ctx, httpCtx); + var result = await handler(ctx, ct).ConfigureAwait(false); + return Results.Json(result); + }).RequireAuthorization(JwtExtensions.EntraPolicy); + }); + + return this; + } + + /// + /// Registers an HTTP POST endpoint at /functions/{name} with no request body. + /// The endpoint is mapped when is called. + /// + /// The function name used in the URL path. + /// The async handler. Its return value is serialized as the JSON response. + /// The current instance for fluent chaining. + public TeamsBotApplicationBuilder WithFunction( + string name, + Func> handler) + { + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + ArgumentNullException.ThrowIfNull(handler, nameof(handler)); + + _functionActions.Add((webApp, botApp) => + { + webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => + { + ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); + FunctionContext ctx = new(botApp, logger); + PopulateClientContext(ctx, httpCtx); + var result = await handler(ctx, ct).ConfigureAwait(false); + return Results.Json(result); + }).RequireAuthorization(JwtExtensions.EntraPolicy); + }); + + return this; + } + + private static void ApplyTab(WebApplication webApp, string name, IFileProvider provider) + { + webApp.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = $"/tabs/{name}", + ServeUnknownFileTypes = true + }); + + webApp.MapGet($"/tabs/{name}", () => + { + IFileInfo file = provider.GetFileInfo("index.html"); + return file.Exists + ? Results.File(file.CreateReadStream(), "text/html") + : Results.NotFound(); + }); + + webApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => + { + IFileInfo file = provider.GetFileInfo(path); + if (!file.Exists) return Results.NotFound(); + _contentTypeProvider.TryGetContentType(file.Name, out var contentType); + return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); + }); + } + + private static void PopulateClientContext(FunctionContext ctx, HttpContext httpCtx) + { + BotApplicationOptions botOptions = httpCtx.RequestServices.GetRequiredService(); + ctx.BotId = botOptions.AppId; + ctx.ServiceUrl = botOptions.ServiceUrl; + + ctx.TenantId = httpCtx.User.FindFirst("tid")?.Value; + ctx.UserId = httpCtx.User.FindFirst("oid")?.Value; + ctx.UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value; + ctx.AuthToken = httpCtx.Request.Headers.Authorization.FirstOrDefault() + ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); + + // X-Teams-* headers sent by the Teams JS client + if (httpCtx.Request.Headers.TryGetValue("X-Teams-App-Session-Id", out var appSessionId)) + ctx.AppSessionId = appSessionId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Page-Id", out var pageId)) + ctx.PageId = pageId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Channel-Id", out var channelId)) + ctx.ChannelId = channelId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Chat-Id", out var chatId)) + ctx.ChatId = chatId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Meeting-Id", out var meetingId)) + ctx.MeetingId = meetingId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Team-Id", out var teamId)) + ctx.TeamId = teamId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Message-Id", out var messageId)) + ctx.MessageId = messageId; + if (httpCtx.Request.Headers.TryGetValue("X-Teams-Sub-Page-Id", out var subPageId)) + ctx.SubPageId = subPageId; + } } From 2f974c4056848a57ae13c4861db019995f64d328 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 14:50:24 -0800 Subject: [PATCH 07/20] refactor func ctx --- .../FunctionContext.cs | 77 ++++++++++--------- .../TeamsBotApplicationBuilder.cs | 39 +--------- 2 files changed, 45 insertions(+), 71 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs index 9fbe3a33..c4854072 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -1,64 +1,66 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; +using Microsoft.Teams.Bot.Core.Hosting; using Microsoft.Teams.Bot.Core.Schema; namespace Microsoft.Teams.Bot.Apps; /// /// Context passed to a server function handler registered via -/// (no request body). +/// . /// -public class FunctionContext(TeamsBotApplication botApp, ILogger log) +public class FunctionContext(TeamsBotApplication botApp, ILogger log, HttpContext httpContext, BotApplicationOptions options) { - /// Gets or sets the bot's application (client) ID./>. - public string? BotId { get; set; } + /// Gets the bot's application (client) ID. + public string? BotId => options.AppId; - /// Gets or sets the Teams bot service URL for proactive messaging. - public Uri? ServiceUrl { get; set; } + /// Gets the Teams bot service URL for proactive messaging. + public Uri? ServiceUrl => options.ServiceUrl; - /// Gets or sets the Microsoft Entra tenant ID, extracted from the request auth token. - public string? TenantId { get; set; } + /// Gets the Microsoft Entra tenant ID, extracted from the request auth token. + public string? TenantId => httpContext.User.FindFirst("tid")?.Value; - /// Gets or sets the Microsoft Entra object ID of the current user, extracted from the request auth token. - public string? UserId { get; set; } + /// Gets the Microsoft Entra object ID of the current user, extracted from the request auth token. + public string? UserId => httpContext.User.FindFirst("oid")?.Value; - /// Gets or sets the name of the current user, extracted from the request auth token. - public string? UserName { get; set; } + /// Gets the name of the current user, extracted from the request auth token. + public string? UserName => httpContext.User.FindFirst(ClaimTypes.Name)?.Value; - /// Gets or sets the MSAL Entra auth token from the request Authorization header. - public string? AuthToken { get; set; } + /// Gets the MSAL Entra auth token from the request Authorization header. + public string? AuthToken => httpContext.Request.Headers.Authorization.FirstOrDefault() + ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); - /// Gets or sets the unique ID for the current app session (X-Teams-App-Session-Id header). - public string? AppSessionId { get; set; } + /// Gets the unique ID for the current app session (X-Teams-App-Session-Id header). + public string? AppSessionId => GetHeader("X-Teams-App-Session-Id"); - /// Gets or sets the developer-defined unique ID for the page (X-Teams-Page-Id header). - public string? PageId { get; set; } + /// Gets the developer-defined unique ID for the page (X-Teams-Page-Id header). + public string? PageId => GetHeader("X-Teams-Page-Id"); - /// Gets or sets the developer-defined unique ID for the sub-page (X-Teams-Sub-Page-Id header). - public string? SubPageId { get; set; } + /// Gets the developer-defined unique ID for the sub-page (X-Teams-Sub-Page-Id header). + public string? SubPageId => GetHeader("X-Teams-Sub-Page-Id"); - /// Gets or sets the Microsoft Teams channel ID (X-Teams-Channel-Id header). - public string? ChannelId { get; set; } + /// Gets the Microsoft Teams channel ID (X-Teams-Channel-Id header). + public string? ChannelId => GetHeader("X-Teams-Channel-Id"); - /// Gets or sets the Microsoft Teams chat ID (X-Teams-Chat-Id header). - public string? ChatId { get; set; } + /// Gets the Microsoft Teams chat ID (X-Teams-Chat-Id header). + public string? ChatId => GetHeader("X-Teams-Chat-Id"); - /// Gets or sets the Microsoft Teams meeting ID (X-Teams-Meeting-Id header). - public string? MeetingId { get; set; } + /// Gets the Microsoft Teams meeting ID (X-Teams-Meeting-Id header). + public string? MeetingId => GetHeader("X-Teams-Meeting-Id"); - /// Gets or sets the Microsoft Teams team ID (X-Teams-Team-Id header). - public string? TeamId { get; set; } + /// Gets the Microsoft Teams team ID (X-Teams-Team-Id header). + public string? TeamId => GetHeader("X-Teams-Team-Id"); - /// Gets or sets the ID of the parent message from which a task module was launched (X-Teams-Message-Id header). - public string? MessageId { get; set; } + /// Gets the ID of the parent message from which a task module was launched (X-Teams-Message-Id header). + public string? MessageId => GetHeader("X-Teams-Message-Id"); - /// - /// Gets the Teams conversation ID. Resolved from or . - /// + /// Gets the Teams conversation ID. Resolved from or . public string? ConversationId => ChatId ?? ChannelId; /// Gets the logger for this function. @@ -92,15 +94,18 @@ public async Task SendAsync(string message, CancellationTo return await botApp.ConversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); } + + private string? GetHeader(string name) => + httpContext.Request.Headers.TryGetValue(name, out var value) ? (string?)value : null; } /// /// Context passed to a server function handler registered via -/// . +/// . /// The deserialized request body is available via . /// -public class FunctionContext(TeamsBotApplication botApp, ILogger log, T data) - : FunctionContext(botApp, log) +public class FunctionContext(TeamsBotApplication botApp, ILogger log, HttpContext httpContext, BotApplicationOptions options, T data) + : FunctionContext(botApp, log, httpContext, options) { /// Gets the deserialized request body. public T Data { get; } = data; diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index ba1da9c8..8c656fb2 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -165,9 +164,9 @@ public TeamsBotApplicationBuilder WithFunction( webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => { ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); + BotApplicationOptions options = httpCtx.RequestServices.GetRequiredService(); TBody? body = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false); - FunctionContext ctx = new(botApp, logger, body!); - PopulateClientContext(ctx, httpCtx); + FunctionContext ctx = new(botApp, logger, httpCtx, options, body!); var result = await handler(ctx, ct).ConfigureAwait(false); return Results.Json(result); }).RequireAuthorization(JwtExtensions.EntraPolicy); @@ -195,8 +194,8 @@ public TeamsBotApplicationBuilder WithFunction( webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => { ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); - FunctionContext ctx = new(botApp, logger); - PopulateClientContext(ctx, httpCtx); + BotApplicationOptions options = httpCtx.RequestServices.GetRequiredService(); + FunctionContext ctx = new(botApp, logger, httpCtx, options); var result = await handler(ctx, ct).ConfigureAwait(false); return Results.Json(result); }).RequireAuthorization(JwtExtensions.EntraPolicy); @@ -231,34 +230,4 @@ private static void ApplyTab(WebApplication webApp, string name, IFileProvider p }); } - private static void PopulateClientContext(FunctionContext ctx, HttpContext httpCtx) - { - BotApplicationOptions botOptions = httpCtx.RequestServices.GetRequiredService(); - ctx.BotId = botOptions.AppId; - ctx.ServiceUrl = botOptions.ServiceUrl; - - ctx.TenantId = httpCtx.User.FindFirst("tid")?.Value; - ctx.UserId = httpCtx.User.FindFirst("oid")?.Value; - ctx.UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value; - ctx.AuthToken = httpCtx.Request.Headers.Authorization.FirstOrDefault() - ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); - - // X-Teams-* headers sent by the Teams JS client - if (httpCtx.Request.Headers.TryGetValue("X-Teams-App-Session-Id", out var appSessionId)) - ctx.AppSessionId = appSessionId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Page-Id", out var pageId)) - ctx.PageId = pageId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Channel-Id", out var channelId)) - ctx.ChannelId = channelId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Chat-Id", out var chatId)) - ctx.ChatId = chatId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Meeting-Id", out var meetingId)) - ctx.MeetingId = meetingId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Team-Id", out var teamId)) - ctx.TeamId = teamId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Message-Id", out var messageId)) - ctx.MessageId = messageId; - if (httpCtx.Request.Headers.TryGetValue("X-Teams-Sub-Page-Id", out var subPageId)) - ctx.SubPageId = subPageId; - } } From 43c3278a6ef06e80b257535daf20c2c2baf9e79b Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 26 Feb 2026 16:42:24 -0800 Subject: [PATCH 08/20] refactor func ctx --- core/samples/TabApp/Body.cs | 2 + core/samples/TabApp/Program.cs | 7 +- core/samples/TabApp/Web/src/App.tsx | 20 +--- .../FunctionContext.cs | 101 ++++++++++-------- .../TeamsBotApplicationBuilder.cs | 92 ++++++++-------- 5 files changed, 108 insertions(+), 114 deletions(-) diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs index ba8d3ffe..9a73d857 100644 --- a/core/samples/TabApp/Body.cs +++ b/core/samples/TabApp/Body.cs @@ -9,3 +9,5 @@ public class PostToChatBody { public required string Message { get; set; } } + +public record PostToChatResult(bool Ok); diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index f4780006..0c472262 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -13,11 +13,10 @@ // ==================== SERVER FUNCTIONS ==================== -builder.WithFunction("post-to-chat", async (ctx, ct) => +builder.WithFunction("post-to-chat", async (ctx, ct) => { - ctx.Log.LogInformation("post-to-chat called by {User} with message: {Message}", ctx.UserName, ctx.Data.Message); - await ctx.SendAsync(ctx.Data.Message, ct); - return new { ok = true }; + await ctx.SendAsync(ctx.Data?.Message?? "", ct); + return new PostToChatResult(Ok: true); }); // TODO: Once SSO is implemented, review moving who-am-i and toggle-status functions to server side diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index 51bae36c..04c6ac44 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -40,23 +40,13 @@ export default function App() { app.getContext(), ]) - const headers: Record = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - } - - if (ctx.app.sessionId) headers['X-Teams-App-Session-Id'] = ctx.app.sessionId - if (ctx.page.id) headers['X-Teams-Page-Id'] = ctx.page.id - if (ctx.page.subPageId) headers['X-Teams-Sub-Page-Id'] = ctx.page.subPageId - if (ctx.channel?.id) headers['X-Teams-Channel-Id'] = ctx.channel.id - if (ctx.chat?.id) headers['X-Teams-Chat-Id'] = ctx.chat.id - if (ctx.meeting?.id) headers['X-Teams-Meeting-Id'] = ctx.meeting.id - if (ctx.team?.groupId) headers['X-Teams-Team-Id'] = ctx.team.groupId - const res = await fetch(`/functions/${name}`, { method: 'POST', - headers, - body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ payload: body, context: ctx }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs index c4854072..951b79e5 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net.Http.Headers; using System.Security.Claims; +using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; @@ -13,15 +16,18 @@ namespace Microsoft.Teams.Bot.Apps; /// /// Context passed to a server function handler registered via -/// . +/// . /// -public class FunctionContext(TeamsBotApplication botApp, ILogger log, HttpContext httpContext, BotApplicationOptions options) +public class FunctionContext(TeamsBotApplication botApp, HttpContext httpContext, FunctionRequest request) { + private readonly BotApplicationOptions _options = + httpContext.RequestServices.GetRequiredService(); + /// Gets the bot's application (client) ID. - public string? BotId => options.AppId; + public string? BotId => _options.AppId; /// Gets the Teams bot service URL for proactive messaging. - public Uri? ServiceUrl => options.ServiceUrl; + public Uri? ServiceUrl => _options.ServiceUrl; /// Gets the Microsoft Entra tenant ID, extracted from the request auth token. public string? TenantId => httpContext.User.FindFirst("tid")?.Value; @@ -33,80 +39,81 @@ public class FunctionContext(TeamsBotApplication botApp, ILogger log, HttpContex public string? UserName => httpContext.User.FindFirst(ClaimTypes.Name)?.Value; /// Gets the MSAL Entra auth token from the request Authorization header. - public string? AuthToken => httpContext.Request.Headers.Authorization.FirstOrDefault() - ?.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase); - - /// Gets the unique ID for the current app session (X-Teams-App-Session-Id header). - public string? AppSessionId => GetHeader("X-Teams-App-Session-Id"); - - /// Gets the developer-defined unique ID for the page (X-Teams-Page-Id header). - public string? PageId => GetHeader("X-Teams-Page-Id"); - - /// Gets the developer-defined unique ID for the sub-page (X-Teams-Sub-Page-Id header). - public string? SubPageId => GetHeader("X-Teams-Sub-Page-Id"); - - /// Gets the Microsoft Teams channel ID (X-Teams-Channel-Id header). - public string? ChannelId => GetHeader("X-Teams-Channel-Id"); - - /// Gets the Microsoft Teams chat ID (X-Teams-Chat-Id header). - public string? ChatId => GetHeader("X-Teams-Chat-Id"); + public string? AuthToken => AuthenticationHeaderValue.TryParse( + httpContext.Request.Headers.Authorization.FirstOrDefault(), out var header) + ? header.Parameter + : null; - /// Gets the Microsoft Teams meeting ID (X-Teams-Meeting-Id header). - public string? MeetingId => GetHeader("X-Teams-Meeting-Id"); + /// Gets the raw Teams context JSON node from the request body. + public JsonNode? TeamsContext => request.Context; - /// Gets the Microsoft Teams team ID (X-Teams-Team-Id header). - public string? TeamId => GetHeader("X-Teams-Team-Id"); - - /// Gets the ID of the parent message from which a task module was launched (X-Teams-Message-Id header). - public string? MessageId => GetHeader("X-Teams-Message-Id"); - - /// Gets the Teams conversation ID. Resolved from or . - public string? ConversationId => ChatId ?? ChannelId; - - /// Gets the logger for this function. - public ILogger Log { get; } = log; + /// Teams conversation ID, resolved after a call to . + public string? ConversationId { get; private set; } /// /// Sends a text message to the conversation proactively. /// public async Task SendAsync(string message, CancellationToken cancellationToken = default) { - var conversationId = ConversationId; + var conversationId = TeamsContext?["chat"]?["id"]?.GetValue() + ?? TeamsContext?["channel"]?["id"]?.GetValue() + ?? ConversationId; // Conversation ID can be missing if the app is running in a personal scope. In this case, create // a conversation between the bot and the user. This will either create a new conversation or return // a pre-existing one. if (conversationId is null) { + if (ServiceUrl is null) + throw new InvalidOperationException("ServiceUrl is not configured. Set BotOptions.ServiceUrl to send proactive messages."); + var res = await botApp.ConversationClient.CreateConversationAsync(new ConversationParameters { TenantId = TenantId, IsGroup = false, Bot = new ConversationAccount { Id = BotId }, Members = [new ConversationAccount { Id = UserId }] - }, ServiceUrl!, cancellationToken: cancellationToken).ConfigureAwait(false); + }, ServiceUrl, cancellationToken: cancellationToken).ConfigureAwait(false); conversationId = res.Id; } - var activity = new MessageActivity(message) { ServiceUrl = ServiceUrl }; + MessageActivity activity = new(message) { ServiceUrl = ServiceUrl }; activity.Conversation.Id = conversationId!; + ConversationId = conversationId; return await botApp.ConversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); } - - private string? GetHeader(string name) => - httpContext.Request.Headers.TryGetValue(name, out var value) ? (string?)value : null; } /// /// Context passed to a server function handler registered via -/// . -/// The deserialized request body is available via . +/// . +/// The deserialized request payload is available via . +/// +public class FunctionContext(TeamsBotApplication botApp, HttpContext httpContext, FunctionRequest request) + : FunctionContext(botApp, httpContext, request) +{ + /// Gets the deserialized request payload. + public T? Data => request.Payload; +} + +/// +/// Represents the JSON body sent by the Teams client to a server function endpoint. +/// +public class FunctionRequest +{ + /// Gets or sets the raw Teams context JSON node. + public JsonNode? Context { get; set; } +} + +/// +/// Represents the JSON body sent by the Teams client to a server function endpoint, +/// with a typed payload. /// -public class FunctionContext(TeamsBotApplication botApp, ILogger log, HttpContext httpContext, BotApplicationOptions options, T data) - : FunctionContext(botApp, log, httpContext, options) +/// The type to deserialize the request payload into. +public sealed class FunctionRequest : FunctionRequest { - /// Gets the deserialized request body. - public T Data { get; } = data; + /// Gets or sets the deserialized request payload. + public T? Payload { get; set; } } diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index 8c656fb2..b16286b6 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -115,7 +115,7 @@ public TeamsBotApplicationBuilder WithTab(string name, string physicalPath) { webApp.Services.GetRequiredService() .ApplicationStopped.Register(provider.Dispose); - ApplyTab(webApp, name, provider); + WithTab(name, provider); } catch { @@ -139,22 +139,47 @@ public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); ArgumentNullException.ThrowIfNull(provider, nameof(provider)); - _tabActions.Add(webApp => ApplyTab(webApp, name, provider)); + _tabActions.Add(webApp => + { + webApp.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = $"/tabs/{name}", + ServeUnknownFileTypes = true + }); + + webApp.MapGet($"/tabs/{name}", () => + { + IFileInfo file = provider.GetFileInfo("index.html"); + return file.Exists + ? Results.File(file.CreateReadStream(), "text/html") + : Results.NotFound(); + }); + + webApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => + { + IFileInfo file = provider.GetFileInfo(path); + if (!file.Exists) return Results.NotFound(); + _contentTypeProvider.TryGetContentType(file.Name, out var contentType); + return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); + }); + }); return this; } /// - /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body. + /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body and typed response. /// The endpoint is mapped when is called. /// /// The type to deserialize the JSON request body into. + /// The type of the value serialized as the JSON response. /// The function name used in the URL path. - /// The async handler. Its return value is serialized as the JSON response. + /// The async handler whose return value is serialized as the JSON response. /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( + public TeamsBotApplicationBuilder WithFunction( string name, - Func, CancellationToken, Task> handler) + Func, CancellationToken, Task> handler) { ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); ArgumentNullException.ThrowIfNull(handler, nameof(handler)); @@ -163,12 +188,10 @@ public TeamsBotApplicationBuilder WithFunction( { webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => { - ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); - BotApplicationOptions options = httpCtx.RequestServices.GetRequiredService(); - TBody? body = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false); - FunctionContext ctx = new(botApp, logger, httpCtx, options, body!); - var result = await handler(ctx, ct).ConfigureAwait(false); - return Results.Json(result); + FunctionRequest request = await httpCtx.Request.ReadFromJsonAsync>(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Missing request body."); + FunctionContext ctx = new(botApp, httpCtx, request); + return Results.Json(await handler(ctx, ct).ConfigureAwait(false)); }).RequireAuthorization(JwtExtensions.EntraPolicy); }); @@ -176,15 +199,16 @@ public TeamsBotApplicationBuilder WithFunction( } /// - /// Registers an HTTP POST endpoint at /functions/{name} with no request body. + /// Registers an HTTP POST endpoint at /functions/{name} with no request body and a typed response. /// The endpoint is mapped when is called. /// + /// The type of the value serialized as the JSON response. /// The function name used in the URL path. - /// The async handler. Its return value is serialized as the JSON response. + /// The async handler whose return value is serialized as the JSON response. /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( + public TeamsBotApplicationBuilder WithFunction( string name, - Func> handler) + Func> handler) { ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); ArgumentNullException.ThrowIfNull(handler, nameof(handler)); @@ -193,41 +217,13 @@ public TeamsBotApplicationBuilder WithFunction( { webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => { - ILogger logger = httpCtx.RequestServices.GetRequiredService().CreateLogger($"functions.{name}"); - BotApplicationOptions options = httpCtx.RequestServices.GetRequiredService(); - FunctionContext ctx = new(botApp, logger, httpCtx, options); - var result = await handler(ctx, ct).ConfigureAwait(false); - return Results.Json(result); + FunctionRequest request = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Missing request body."); + FunctionContext ctx = new(botApp, httpCtx, request); + return Results.Json(await handler(ctx, ct).ConfigureAwait(false)); }).RequireAuthorization(JwtExtensions.EntraPolicy); }); return this; } - - private static void ApplyTab(WebApplication webApp, string name, IFileProvider provider) - { - webApp.UseStaticFiles(new StaticFileOptions - { - FileProvider = provider, - RequestPath = $"/tabs/{name}", - ServeUnknownFileTypes = true - }); - - webApp.MapGet($"/tabs/{name}", () => - { - IFileInfo file = provider.GetFileInfo("index.html"); - return file.Exists - ? Results.File(file.CreateReadStream(), "text/html") - : Results.NotFound(); - }); - - webApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => - { - IFileInfo file = provider.GetFileInfo(path); - if (!file.Exists) return Results.NotFound(); - _contentTypeProvider.TryGetContentType(file.Name, out var contentType); - return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); - }); - } - } From c6db39ead25dd9a455e7aec6ed588d59978d8ead Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Fri, 27 Feb 2026 12:15:29 -0800 Subject: [PATCH 09/20] auth fix --- core/samples/TabApp/Web/src/App.tsx | 84 ++++++++---------- .../FunctionContext.cs | 1 - .../Hosting/JwtExtensions.cs | 85 ++++++++++++------- 3 files changed, 89 insertions(+), 81 deletions(-) diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index 04c6ac44..1e3c595e 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -4,6 +4,7 @@ import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/ms let _msal: PublicClientApplication | null = null +//TODO : review if we should take a dependency on microsoft/teams.client async function getMsal(tenantId: string): Promise { if (!_msal) { _msal = new PublicClientApplication({ @@ -18,6 +19,27 @@ async function getMsal(tenantId: string): Promise { return _msal } +async function acquireToken(scopes: string[], context: app.Context | null): Promise { + const tenantId = context?.user?.tenant?.id ?? 'common' + const loginHint = context?.user?.loginHint + const msal = await getMsal(tenantId) + + const accounts = msal.getAllAccounts() + const account = loginHint + ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) + : accounts[0] + + try { + if (!account) throw new InteractionRequiredAuthError('no_account') + const result = await msal.acquireTokenSilent({ scopes, account }) + return result.accessToken + } catch (e) { + if (!(e instanceof InteractionRequiredAuthError)) throw e + const result = await msal.acquireTokenPopup({ scopes, loginHint }) + return result.accessToken + } +} + export default function App() { const [context, setContext] = useState(null) const [message, setMessage] = useState('Hello from the tab!') @@ -64,71 +86,37 @@ export default function App() { const showContext = useCallback(() => run(async () => context), [context]) const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message })), [message]) const whoAmI = useCallback(() => run(async () => { - const tenantId = context?.user?.tenant?.id ?? 'common' - const loginHint = context?.user?.loginHint - const msal = await getMsal(tenantId) - const scopes = ['User.Read'] - - const accounts = msal.getAllAccounts() - const account = loginHint - ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) - : accounts[0] - - let accessToken: string - try { - if (!account) throw new InteractionRequiredAuthError('no_account') - const result = await msal.acquireTokenSilent({ scopes, account }) - accessToken = result.accessToken - } catch (e) { - if (!(e instanceof InteractionRequiredAuthError)) throw e - const result = await msal.acquireTokenPopup({ scopes, loginHint }) - accessToken = result.accessToken - } - + const accessToken = await acquireToken(['User.Read'], context) return fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${accessToken}` }, }).then(r => r.json()) }), [context]) - // TODO: Move whoAmI and toggleStatus to server-side bot functions once SSO OBO is implemented, - // so Graph token acquisition happens on the server via the On-Behalf-Of flow. const toggleStatus = useCallback(() => run(async () => { - const tenantId = context?.user?.tenant?.id ?? 'common' - const loginHint = context?.user?.loginHint - const msal = await getMsal(tenantId) - const scopes = ['Presence.ReadWrite'] - - const accounts = msal.getAllAccounts() - const account = loginHint - ? (accounts.find(a => a.username === loginHint) ?? accounts[0]) - : accounts[0] + const accessToken = await acquireToken(['Presence.ReadWrite'], context) - let accessToken: string - try { - if (!account) throw new InteractionRequiredAuthError('no_account') - const result = await msal.acquireTokenSilent({ scopes, account }) - accessToken = result.accessToken - } catch (e) { - if (!(e instanceof InteractionRequiredAuthError)) throw e - const result = await msal.acquireTokenPopup({ scopes, loginHint }) - accessToken = result.accessToken - } + const presenceRes = await fetch('https://graph.microsoft.com/v1.0/me/presence', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!presenceRes.ok) throw new Error(`Graph ${presenceRes.status}`) + const { availability: current } = await presenceRes.json() - const newStatus = !status - const availability = newStatus ? 'DoNotDisturb' : 'Available' + const isAvailable = current === 'Available' + const availability = isAvailable ? 'DoNotDisturb' : 'Available' + const activity = isAvailable ? 'Presenting' : 'Available' const res = await fetch('https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ availability, activity: availability }), + body: JSON.stringify({ availability, activity }), }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(`Graph ${res.status}: ${JSON.stringify(body)}`) } - setStatus(newStatus) - return { availability } - }), [status, context]) + setStatus(availability === 'DoNotDisturb') + return { availability, activity } + }), [context]) if (!initialized) { return
Initializing Teams SDK…
diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs index 951b79e5..040969a9 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -5,7 +5,6 @@ using System.Security.Claims; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 0471eabd..8d012bdc 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; -using System.Linq; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -70,10 +70,16 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection } // Register the Entra user token scheme for tab function endpoints. - string[] entraIssuers = string.IsNullOrEmpty(tenantId) - ? ["https://login.microsoftonline.com/common/v2.0"] - : [$"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/"]; - builder.AddCustomJwtBearer(EntraScheme, entraIssuers, audience, logger); + if (string.IsNullOrEmpty(tenantId)) + { + // Validate dynamically by constructing the expected issuer from the token's tid claim. + builder.AddCustomJwtBearer(EntraScheme, [], audience, logger, ValidateMultiTenantEntraIssuer); + } + else + { + string[] entraIssuers = [$"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/"]; + builder.AddCustomJwtBearer(EntraScheme, entraIssuers, audience, logger); + } return builder; } @@ -134,8 +140,24 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv return authorizationBuilder; } - private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string schemeName, string[] validIssuers, string audience, ILogger? logger) + private static string ValidateMultiTenantEntraIssuer(string issuer, SecurityToken token, TokenValidationParameters parameters) + { + if (token is JwtSecurityToken jwt) + { + var tid = jwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; + if (tid != null && + (issuer == $"https://login.microsoftonline.com/{tid}/v2.0" || + issuer == $"https://sts.windows.net/{tid}/")) + return issuer; + } + throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid for multi-tenant Entra authentication."); + } + + private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string schemeName, string[] validIssuers, string audience, ILogger? logger, IssuerValidator? issuerValidator = null) { + // One ConfigurationManager per OIDC authority, shared safely across all requests. + ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); + builder.AddJwtBearer(schemeName, jwtOptions => { jwtOptions.SaveToken = true; @@ -147,7 +169,28 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild RequireSignedTokens = true, ValidateIssuer = true, ValidateAudience = true, - ValidIssuers = validIssuers + ValidIssuers = issuerValidator is null ? validIssuers : null, + IssuerValidator = issuerValidator, + IssuerSigningKeyResolver = (_, securityToken, kid, _) => + { + if (securityToken is not JwtSecurityToken jwt) return []; + + string iss = jwt.Issuer; + string? tid = jwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; + + string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) + ? BotOIDC + : $"{AgentOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; + + ConfigurationManager manager = configManagerCache.GetOrAdd(authority, a => + new ConfigurationManager( + a, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata })); + + var config = manager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult(); + return config.SigningKeys; + } }; jwtOptions.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); jwtOptions.MapInboundClaims = true; @@ -166,41 +209,19 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild if (string.IsNullOrEmpty(authorizationHeader)) { - // Default to AadTokenValidation handling - context.Options.TokenValidationParameters.ConfigurationManager ??= jwtOptions.ConfigurationManager as BaseConfigurationManager; - await Task.CompletedTask.ConfigureAwait(false); requestLogger.LogWarning("Authorization header is missing for scheme: {Scheme}", schemeName); + await Task.CompletedTask.ConfigureAwait(false); return; } - string[] parts = authorizationHeader?.Split(' ')!; + string[] parts = authorizationHeader.Split(' '); if (parts.Length != 2 || parts[0] != "Bearer") { - // Default to AadTokenValidation handling - context.Options.TokenValidationParameters.ConfigurationManager ??= jwtOptions.ConfigurationManager as BaseConfigurationManager; - await Task.CompletedTask.ConfigureAwait(false); requestLogger.LogWarning("Invalid authorization header format for scheme: {Scheme}", schemeName); + await Task.CompletedTask.ConfigureAwait(false); return; } - JwtSecurityToken token = new(parts[1]); - string issuer = token.Claims.FirstOrDefault(claim => claim.Type == "iss")?.Value!; - string tid = token.Claims.FirstOrDefault(claim => claim.Type == "tid")?.Value!; - - string oidcAuthority = issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) - ? BotOIDC : $"{AgentOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; - - requestLogger.LogDebug("Using OIDC Authority: {OidcAuthority} for issuer: {Issuer}", oidcAuthority, issuer); - - jwtOptions.ConfigurationManager = new ConfigurationManager( - oidcAuthority, - new OpenIdConnectConfigurationRetriever(), - new HttpDocumentRetriever - { - RequireHttps = jwtOptions.RequireHttpsMetadata - }); - - await Task.CompletedTask.ConfigureAwait(false); }, OnTokenValidated = context => From 02267cd1beb465849755265274405705a133d6dd Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Fri, 27 Feb 2026 12:54:40 -0800 Subject: [PATCH 10/20] simplify function ctx --- core/samples/TabApp/Program.cs | 4 +- .../FunctionContext.cs | 32 +++++--------- .../TeamsBotApplicationBuilder.cs | 42 ++++++++----------- .../BotApplication.cs | 8 ++-- 4 files changed, 34 insertions(+), 52 deletions(-) diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index 0c472262..e9d12fb7 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -13,9 +13,9 @@ // ==================== SERVER FUNCTIONS ==================== -builder.WithFunction("post-to-chat", async (ctx, ct) => +builder.WithFunction("post-to-chat", async (ctx, ct) => { - await ctx.SendAsync(ctx.Data?.Message?? "", ct); + await ctx.SendAsync(ctx.Data?.Message ?? "", ct); return new PostToChatResult(Ok: true); }); diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs index 040969a9..604a089e 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -1,14 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Net.Http.Headers; -using System.Security.Claims; using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Hosting; using Microsoft.Teams.Bot.Core.Schema; namespace Microsoft.Teams.Bot.Apps; @@ -17,34 +12,28 @@ namespace Microsoft.Teams.Bot.Apps; /// Context passed to a server function handler registered via /// . /// -public class FunctionContext(TeamsBotApplication botApp, HttpContext httpContext, FunctionRequest request) +public class FunctionContext(TeamsBotApplication botApp) { - private readonly BotApplicationOptions _options = - httpContext.RequestServices.GetRequiredService(); - /// Gets the bot's application (client) ID. - public string? BotId => _options.AppId; + public string BotId => botApp.Options.AppId; /// Gets the Teams bot service URL for proactive messaging. - public Uri? ServiceUrl => _options.ServiceUrl; + public Uri ServiceUrl => botApp.Options.ServiceUrl; /// Gets the Microsoft Entra tenant ID, extracted from the request auth token. - public string? TenantId => httpContext.User.FindFirst("tid")?.Value; + public string? TenantId { get; init; } /// Gets the Microsoft Entra object ID of the current user, extracted from the request auth token. - public string? UserId => httpContext.User.FindFirst("oid")?.Value; + public string? UserId { get; init; } /// Gets the name of the current user, extracted from the request auth token. - public string? UserName => httpContext.User.FindFirst(ClaimTypes.Name)?.Value; + public string? UserName { get; init; } /// Gets the MSAL Entra auth token from the request Authorization header. - public string? AuthToken => AuthenticationHeaderValue.TryParse( - httpContext.Request.Headers.Authorization.FirstOrDefault(), out var header) - ? header.Parameter - : null; + public string? AuthToken { get; init; } /// Gets the raw Teams context JSON node from the request body. - public JsonNode? TeamsContext => request.Context; + public JsonNode? TeamsContext { get; init; } /// Teams conversation ID, resolved after a call to . public string? ConversationId { get; private set; } @@ -90,11 +79,10 @@ public async Task SendAsync(string message, CancellationTo /// . /// The deserialized request payload is available via . /// -public class FunctionContext(TeamsBotApplication botApp, HttpContext httpContext, FunctionRequest request) - : FunctionContext(botApp, httpContext, request) +public class FunctionContext(TeamsBotApplication botApp) : FunctionContext(botApp) { /// Gets the deserialized request payload. - public T? Data => request.Payload; + public T? Data { get; init; } } /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index b16286b6..79683ed4 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net.Http.Headers; +using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -173,13 +175,12 @@ public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) /// The endpoint is mapped when is called. /// /// The type to deserialize the JSON request body into. - /// The type of the value serialized as the JSON response. /// The function name used in the URL path. /// The async handler whose return value is serialized as the JSON response. /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( + public TeamsBotApplicationBuilder WithFunction( string name, - Func, CancellationToken, Task> handler) + Func, CancellationToken, Task> handler) { ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); ArgumentNullException.ThrowIfNull(handler, nameof(handler)); @@ -190,7 +191,15 @@ public TeamsBotApplicationBuilder WithFunction( { FunctionRequest request = await httpCtx.Request.ReadFromJsonAsync>(ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Missing request body."); - FunctionContext ctx = new(botApp, httpCtx, request); + FunctionContext ctx = new(botApp) + { + TenantId = httpCtx.User.FindFirst("tid")?.Value, + UserId = httpCtx.User.FindFirst("oid")?.Value, + UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value, + AuthToken = AuthenticationHeaderValue.TryParse(httpCtx.Request.Headers.Authorization.FirstOrDefault(), out var header) ? header.Parameter : null, + TeamsContext = request.Context, + Data = request.Payload, + }; return Results.Json(await handler(ctx, ct).ConfigureAwait(false)); }).RequireAuthorization(JwtExtensions.EntraPolicy); }); @@ -199,31 +208,14 @@ public TeamsBotApplicationBuilder WithFunction( } /// - /// Registers an HTTP POST endpoint at /functions/{name} with no request body and a typed response. + /// Registers an HTTP POST endpoint at /functions/{name} with no request body. /// The endpoint is mapped when is called. /// - /// The type of the value serialized as the JSON response. /// The function name used in the URL path. /// The async handler whose return value is serialized as the JSON response. /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( + public TeamsBotApplicationBuilder WithFunction( string name, - Func> handler) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(handler, nameof(handler)); - - _functionActions.Add((webApp, botApp) => - { - webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => - { - FunctionRequest request = await httpCtx.Request.ReadFromJsonAsync(ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("Missing request body."); - FunctionContext ctx = new(botApp, httpCtx, request); - return Results.Json(await handler(ctx, ct).ConfigureAwait(false)); - }).RequireAuthorization(JwtExtensions.EntraPolicy); - }); - - return this; - } + Func> handler) + => WithFunction(name, (ctx, ct) => handler(ctx, ct)); } diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs index b77ff7cd..4ac07f78 100644 --- a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -32,15 +32,17 @@ public class BotApplication /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger logger, BotApplicationOptions? options = null) { - options ??= new(); + Options = options ?? new(); _logger = logger; MiddleWare = new TurnMiddleware(); _conversationClient = conversationClient; _userTokenClient = userTokenClient; - logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); - logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); + logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, Options.AppId, Version); } + /// Gets the options this bot application was configured with. + public BotApplicationOptions Options { get; } + /// /// Gets the client used to manage and interact with conversations. From 54828fab2cae0f7fa774a0f5d800f53c1afde40c Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Fri, 27 Feb 2026 14:20:33 -0800 Subject: [PATCH 11/20] fixes --- core/samples/TabApp/Program.cs | 2 +- core/samples/TabApp/Web/src/App.tsx | 41 +++++++++---------- .../FunctionContext.cs | 3 +- .../TeamsBotApplicationBuilder.cs | 37 ++++++----------- .../Hosting/JwtExtensions.cs | 33 ++++++++------- 5 files changed, 53 insertions(+), 63 deletions(-) diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index e9d12fb7..667d1a26 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -19,7 +19,7 @@ return new PostToChatResult(Ok: true); }); -// TODO: Once SSO is implemented, review moving who-am-i and toggle-status functions to server side +// TODO: Once SSO is implemented, review graph calls via bot instead of client. var app = builder.Build(); diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index 1e3c595e..c5550e94 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -1,28 +1,23 @@ import { useState, useEffect, useCallback } from 'react' -import { app, authentication } from '@microsoft/teams-js' -import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser' - -let _msal: PublicClientApplication | null = null - -//TODO : review if we should take a dependency on microsoft/teams.client -async function getMsal(tenantId: string): Promise { - if (!_msal) { - _msal = new PublicClientApplication({ - auth: { - clientId: import.meta.env.VITE_CLIENT_ID as string, - authority: `https://login.microsoftonline.com/${tenantId}`, - redirectUri: window.location.origin + window.location.pathname, - }, +import { app } from '@microsoft/teams-js' +import { createNestablePublicClientApplication, InteractionRequiredAuthError, IPublicClientApplication } from '@azure/msal-browser' + +const clientId = import.meta.env.VITE_CLIENT_ID as string +let _msal: IPublicClientApplication + +//TODO : do we want to take dependency on teams.client +async function getMsal(): Promise { + if (!_msal) { + _msal = await createNestablePublicClientApplication({ + auth: { clientId, authority: '', redirectUri: '/' }, }) - await _msal.initialize() } - return _msal + return _msal } async function acquireToken(scopes: string[], context: app.Context | null): Promise { - const tenantId = context?.user?.tenant?.id ?? 'common' const loginHint = context?.user?.loginHint - const msal = await getMsal(tenantId) + const msal = await getMsal() const accounts = msal.getAllAccounts() const account = loginHint @@ -57,8 +52,9 @@ export default function App() { }, []) async function callFunction(name: string, body: unknown): Promise { - const [token, ctx] = await Promise.all([ - authentication.getAuthToken(), + const msal = await getMsal() + const [{ accessToken }, ctx] = await Promise.all([ + msal.acquireTokenSilent({ scopes: [`api://${clientId}/access_as_user`] }), app.getContext(), ]) @@ -66,10 +62,11 @@ export default function App() { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${accessToken}`, }, body: JSON.stringify({ payload: body, context: ctx }), }) + //TODO : pass entire ctx or specific fields ? if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } @@ -103,7 +100,7 @@ export default function App() { const isAvailable = current === 'Available' const availability = isAvailable ? 'DoNotDisturb' : 'Available' - const activity = isAvailable ? 'Presenting' : 'Available' + const activity = isAvailable ? 'DoNotDisturb' : 'Available' const res = await fetch('https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence', { method: 'POST', diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs index 604a089e..28f4ed86 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs @@ -32,6 +32,7 @@ public class FunctionContext(TeamsBotApplication botApp) /// Gets the MSAL Entra auth token from the request Authorization header. public string? AuthToken { get; init; } + //TODO : review if we should parse out more fields from the Teams context and make them first-class properties (e.g. chat vs channel, team id, etc.) /// Gets the raw Teams context JSON node from the request body. public JsonNode? TeamsContext { get; init; } @@ -76,7 +77,7 @@ public async Task SendAsync(string message, CancellationTo /// /// Context passed to a server function handler registered via -/// . +/// . /// The deserialized request payload is available via . /// public class FunctionContext(TeamsBotApplication botApp) : FunctionContext(botApp) diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index 79683ed4..d5fd666f 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -67,7 +67,7 @@ public TeamsBotApplicationBuilder(string[] args) /// /// Builds and configures the bot application pipeline, returning a fully initialized instance of the bot - /// application. All registered tabs and functions are mapped to the web application at this point. + /// application. /// /// A configured instance. public TeamsBotApplication Build() @@ -76,10 +76,11 @@ public TeamsBotApplication Build() TeamsBotApplication botApp = _webApp.Services.GetService() ?? throw new InvalidOperationException("Application not registered"); _webApp.UseBotApplication(_routePath); - foreach (var tabAction in _tabActions) + // TODO : review this app builder class + foreach (var tabAction in _tabActions.ToList()) tabAction(_webApp); - foreach (var funcAction in _functionActions) + foreach (var funcAction in _functionActions.ToList()) funcAction(_webApp, botApp); return botApp; @@ -103,30 +104,14 @@ public TeamsBotApplicationBuilder WithRoutePath(string routePath) /// The tab name used in the URL path. /// Absolute or relative path to the directory containing the tab's static files. /// The current instance for fluent chaining. - [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", - Justification = "Provider is disposed in catch on failure; on success disposal is registered with IHostApplicationLifetime.ApplicationStopped.")] public TeamsBotApplicationBuilder WithTab(string name, string physicalPath) { ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); ArgumentException.ThrowIfNullOrEmpty(physicalPath, nameof(physicalPath)); - _tabActions.Add(webApp => - { - PhysicalFileProvider provider = new(Path.GetFullPath(physicalPath)); - try - { - webApp.Services.GetRequiredService() - .ApplicationStopped.Register(provider.Dispose); - WithTab(name, provider); - } - catch - { - provider.Dispose(); - throw; - } - }); - - return this; +#pragma warning disable CA2000 // Dispose objects before losing scope + return WithTab(name, new PhysicalFileProvider(Path.GetFullPath(physicalPath))); +#pragma warning restore CA2000 // Dispose objects before losing scope } /// @@ -143,6 +128,10 @@ public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) _tabActions.Add(webApp => { + if (provider is IDisposable disposable) + webApp.Services.GetRequiredService() + .ApplicationStopped.Register(disposable.Dispose); + webApp.UseStaticFiles(new StaticFileOptions { FileProvider = provider, @@ -152,7 +141,7 @@ public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) webApp.MapGet($"/tabs/{name}", () => { - IFileInfo file = provider.GetFileInfo("index.html"); + IFileInfo file = provider.GetFileInfo("/index.html"); return file.Exists ? Results.File(file.CreateReadStream(), "text/html") : Results.NotFound(); @@ -160,7 +149,7 @@ public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) webApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => { - IFileInfo file = provider.GetFileInfo(path); + IFileInfo file = provider.GetFileInfo($"/{path}"); if (!file.Exists) return Results.NotFound(); _contentTypeProvider.TryGetContentType(file.Name, out var contentType); return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 8d012bdc..12be9d16 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -69,7 +70,6 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection builder.AddCustomJwtBearer(AgentScheme, validIssuers, audience, logger); } - // Register the Entra user token scheme for tab function endpoints. if (string.IsNullOrEmpty(tenantId)) { // Validate dynamically by constructing the expected issuer from the token's tid claim. @@ -140,16 +140,21 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv return authorizationBuilder; } + private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => token switch + { + JsonWebToken jwt => (jwt.Issuer, jwt.TryGetClaim("tid", out var c) ? c.Value : null), + JwtSecurityToken legacy => (legacy.Issuer, legacy.Claims.FirstOrDefault(c => c.Type == "tid")?.Value), + _ => (null, null) + }; + private static string ValidateMultiTenantEntraIssuer(string issuer, SecurityToken token, TokenValidationParameters parameters) { - if (token is JwtSecurityToken jwt) - { - var tid = jwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; - if (tid != null && - (issuer == $"https://login.microsoftonline.com/{tid}/v2.0" || - issuer == $"https://sts.windows.net/{tid}/")) - return issuer; - } + var (_, tid) = GetTokenClaims(token); + if (tid != null && + (issuer == $"https://login.microsoftonline.com/{tid}/v2.0" || + issuer == $"https://sts.windows.net/{tid}/")) + return issuer; + throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid for multi-tenant Entra authentication."); } @@ -171,12 +176,10 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild ValidateAudience = true, ValidIssuers = issuerValidator is null ? validIssuers : null, IssuerValidator = issuerValidator, - IssuerSigningKeyResolver = (_, securityToken, kid, _) => + IssuerSigningKeyResolver = (_, securityToken, _, _) => { - if (securityToken is not JwtSecurityToken jwt) return []; - - string iss = jwt.Issuer; - string? tid = jwt.Claims.FirstOrDefault(c => c.Type == "tid")?.Value; + var (iss, tid) = GetTokenClaims(securityToken); + if (iss is null) return []; string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) ? BotOIDC @@ -188,7 +191,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever { RequireHttps = jwtOptions.RequireHttpsMetadata })); - var config = manager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult(); + OpenIdConnectConfiguration config = manager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult(); return config.SigningKeys; } }; From 76c6b5ce462ab65e0b372e88bade21c8bad81691 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Fri, 27 Feb 2026 15:16:40 -0800 Subject: [PATCH 12/20] rm multiple logging --- core/src/Microsoft.Teams.Bot.Core/BotApplication.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs index 5aca4006..1123d6f9 100644 --- a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -36,9 +36,7 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use MiddleWare = new TurnMiddleware(); _conversationClient = conversationClient; _userTokenClient = userTokenClient; - logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, Options.AppId, Version); - logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); } /// Gets the options this bot application was configured with. From 26bb4261cc64f554e70319c36eb27e86bd639e68 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Mon, 2 Mar 2026 21:34:20 -0800 Subject: [PATCH 13/20] rm tabs from botapp --- core/samples/TabApp/Body.cs | 2 - core/samples/TabApp/Program.cs | 50 ++++--- core/samples/TabApp/Web/src/App.tsx | 8 +- core/samples/TabApp/appsettings.json | 2 +- .../FunctionContext.cs | 107 -------------- .../TeamsBotApplicationBuilder.cs | 134 +----------------- .../BotApplication.cs | 7 +- .../Hosting/AddBotApplicationExtensions.cs | 4 +- .../Hosting/BotApplicationOptions.cs | 7 - 9 files changed, 43 insertions(+), 278 deletions(-) delete mode 100644 core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs index 9a73d857..a7bad304 100644 --- a/core/samples/TabApp/Body.cs +++ b/core/samples/TabApp/Body.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Concurrent; - namespace TabApp; public class PostToChatBody diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index 667d1a26..481212e7 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -1,34 +1,48 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Teams.Bot.Apps; -using Microsoft.Teams.Bot.Apps.Handlers; -using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; +using Microsoft.Teams.Bot.Core.Hosting; using TabApp; -var builder = TeamsBotApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); +builder.Services.AddAuthorization(logger: null); +WebApplication app = builder.Build(); -// Serve the React tab at /tabs/test (build the web app first: cd Web && npm install && npm run build) -builder.WithTab("test", "./Web/bin"); +app.UseAuthentication(); +app.UseAuthorization(); + +// ==================== TABS ==================== -// ==================== SERVER FUNCTIONS ==================== +// Serve the React tab at /tabs/test (build the web app first: cd Web && npm install && npm run build) +var tabProvider = new PhysicalFileProvider(Path.GetFullPath("./Web/bin")); +var contentTypeProvider = new FileExtensionContentTypeProvider(); -builder.WithFunction("post-to-chat", async (ctx, ct) => +app.UseStaticFiles(new StaticFileOptions { - await ctx.SendAsync(ctx.Data?.Message ?? "", ct); - return new PostToChatResult(Ok: true); + FileProvider = tabProvider, + RequestPath = "/tabs/test", + ServeUnknownFileTypes = true }); -// TODO: Once SSO is implemented, review graph calls via bot instead of client. +app.MapGet("/tabs/test/{*path}", (string path) => +{ + IFileInfo file = tabProvider.GetFileInfo($"/{path}"); + if (!file.Exists) return Results.NotFound(); + contentTypeProvider.TryGetContentType(file.Name, out var contentType); + return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); +}); -var app = builder.Build(); -// ==================== MESSAGE ==================== -app.OnMessage(async (ctx, ct) => +app.MapPost("/functions/post-to-chat", async ( + PostToChatBody body, + HttpContext httpCtx, + ILogger logger, + CancellationToken ct) => { - await ctx.SendActivityAsync( - new MessageActivity("Open the **Tab** tab to interact with the sample."), - ct); -}); + logger.LogInformation("post-to-chat called by user {UserId}", httpCtx.User.FindFirst("oid")?.Value); + return Results.Json(new PostToChatResult(Ok: true)); +}).RequireAuthorization("EntraPolicy"); app.Run(); diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index c5550e94..4f2fc727 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -53,10 +53,7 @@ export default function App() { async function callFunction(name: string, body: unknown): Promise { const msal = await getMsal() - const [{ accessToken }, ctx] = await Promise.all([ - msal.acquireTokenSilent({ scopes: [`api://${clientId}/access_as_user`] }), - app.getContext(), - ]) + const { accessToken } = await msal.acquireTokenSilent({ scopes: [`api://${clientId}/access_as_user`] }) const res = await fetch(`/functions/${name}`, { method: 'POST', @@ -64,9 +61,8 @@ export default function App() { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, }, - body: JSON.stringify({ payload: body, context: ctx }), + body: JSON.stringify(body), }) - //TODO : pass entire ctx or specific fields ? if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } diff --git a/core/samples/TabApp/appsettings.json b/core/samples/TabApp/appsettings.json index 5febf4fe..5ebb41d0 100644 --- a/core/samples/TabApp/appsettings.json +++ b/core/samples/TabApp/appsettings.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Warning", + "Default": "Information", "Microsoft.Teams": "Information" } }, diff --git a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs b/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs deleted file mode 100644 index 28f4ed86..00000000 --- a/core/src/Microsoft.Teams.Bot.Apps/FunctionContext.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; -using Microsoft.Teams.Bot.Apps.Schema; -using Microsoft.Teams.Bot.Core; -using Microsoft.Teams.Bot.Core.Schema; - -namespace Microsoft.Teams.Bot.Apps; - -/// -/// Context passed to a server function handler registered via -/// . -/// -public class FunctionContext(TeamsBotApplication botApp) -{ - /// Gets the bot's application (client) ID. - public string BotId => botApp.Options.AppId; - - /// Gets the Teams bot service URL for proactive messaging. - public Uri ServiceUrl => botApp.Options.ServiceUrl; - - /// Gets the Microsoft Entra tenant ID, extracted from the request auth token. - public string? TenantId { get; init; } - - /// Gets the Microsoft Entra object ID of the current user, extracted from the request auth token. - public string? UserId { get; init; } - - /// Gets the name of the current user, extracted from the request auth token. - public string? UserName { get; init; } - - /// Gets the MSAL Entra auth token from the request Authorization header. - public string? AuthToken { get; init; } - - //TODO : review if we should parse out more fields from the Teams context and make them first-class properties (e.g. chat vs channel, team id, etc.) - /// Gets the raw Teams context JSON node from the request body. - public JsonNode? TeamsContext { get; init; } - - /// Teams conversation ID, resolved after a call to . - public string? ConversationId { get; private set; } - - /// - /// Sends a text message to the conversation proactively. - /// - public async Task SendAsync(string message, CancellationToken cancellationToken = default) - { - var conversationId = TeamsContext?["chat"]?["id"]?.GetValue() - ?? TeamsContext?["channel"]?["id"]?.GetValue() - ?? ConversationId; - - // Conversation ID can be missing if the app is running in a personal scope. In this case, create - // a conversation between the bot and the user. This will either create a new conversation or return - // a pre-existing one. - if (conversationId is null) - { - if (ServiceUrl is null) - throw new InvalidOperationException("ServiceUrl is not configured. Set BotOptions.ServiceUrl to send proactive messages."); - - var res = await botApp.ConversationClient.CreateConversationAsync(new ConversationParameters - { - TenantId = TenantId, - IsGroup = false, - Bot = new ConversationAccount { Id = BotId }, - Members = [new ConversationAccount { Id = UserId }] - }, ServiceUrl, cancellationToken: cancellationToken).ConfigureAwait(false); - - conversationId = res.Id; - } - - MessageActivity activity = new(message) { ServiceUrl = ServiceUrl }; - activity.Conversation.Id = conversationId!; - ConversationId = conversationId; - - return await botApp.ConversationClient.SendActivityAsync(activity, cancellationToken: cancellationToken).ConfigureAwait(false); - } -} - -/// -/// Context passed to a server function handler registered via -/// . -/// The deserialized request payload is available via . -/// -public class FunctionContext(TeamsBotApplication botApp) : FunctionContext(botApp) -{ - /// Gets the deserialized request payload. - public T? Data { get; init; } -} - -/// -/// Represents the JSON body sent by the Teams client to a server function endpoint. -/// -public class FunctionRequest -{ - /// Gets or sets the raw Teams context JSON node. - public JsonNode? Context { get; set; } -} - -/// -/// Represents the JSON body sent by the Teams client to a server function endpoint, -/// with a typed payload. -/// -/// The type to deserialize the request payload into. -public sealed class FunctionRequest : FunctionRequest -{ - /// Gets or sets the deserialized request payload. - public T? Payload { get; set; } -} diff --git a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs index 62f36c7f..9fd3c8f7 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/TeamsBotApplicationBuilder.cs @@ -1,16 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Net.Http.Headers; -using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Teams.Bot.Core.Hosting; @@ -24,11 +18,12 @@ public class TeamsBotApplicationBuilder private readonly WebApplicationBuilder _webAppBuilder; private WebApplication? _webApp; private string _routePath = "/api/messages"; - private readonly List> _tabActions = []; - private readonly List> _functionActions = []; - private static readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); - internal WebApplication WebApplication => _webApp ?? throw new InvalidOperationException("Call Build"); + /// + /// The underlying ASP.NET , available after is called. + /// Use this to register routes, middleware, and static files using standard ASP.NET infrastructure. + /// + public WebApplication WebApplication => _webApp ?? throw new InvalidOperationException("Call Build"); /// /// Accessor for the service collection used to configure application services. @@ -75,14 +70,6 @@ public TeamsBotApplication Build() _webApp = _webAppBuilder.Build(); TeamsBotApplication botApp = _webApp.Services.GetService() ?? throw new InvalidOperationException("Application not registered"); _webApp.UseBotApplication(_routePath); - - // TODO : review this app builder class - foreach (var tabAction in _tabActions.ToList()) - tabAction(_webApp); - - foreach (var funcAction in _functionActions.ToList()) - funcAction(_webApp, botApp); - return botApp; } @@ -96,115 +83,4 @@ public TeamsBotApplicationBuilder WithRoutePath(string routePath) _routePath = routePath; return this; } - - /// - /// Registers a tab to be hosted at /tabs/{name}, serving files from the given physical directory. - /// Routes are mapped when is called. - /// - /// The tab name used in the URL path. - /// Absolute or relative path to the directory containing the tab's static files. - /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithTab(string name, string physicalPath) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentException.ThrowIfNullOrEmpty(physicalPath, nameof(physicalPath)); - -#pragma warning disable CA2000 // Dispose objects before losing scope - return WithTab(name, new PhysicalFileProvider(Path.GetFullPath(physicalPath))); -#pragma warning restore CA2000 // Dispose objects before losing scope - } - - /// - /// Registers a tab to be hosted at /tabs/{name}, serving files from the given file provider. - /// Routes are mapped when is called. - /// - /// The tab name used in the URL path. - /// File provider that supplies the tab's static files. - /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithTab(string name, IFileProvider provider) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(provider, nameof(provider)); - - _tabActions.Add(webApp => - { - if (provider is IDisposable disposable) - webApp.Services.GetRequiredService() - .ApplicationStopped.Register(disposable.Dispose); - - webApp.UseStaticFiles(new StaticFileOptions - { - FileProvider = provider, - RequestPath = $"/tabs/{name}", - ServeUnknownFileTypes = true - }); - - webApp.MapGet($"/tabs/{name}", () => - { - IFileInfo file = provider.GetFileInfo("/index.html"); - return file.Exists - ? Results.File(file.CreateReadStream(), "text/html") - : Results.NotFound(); - }); - - webApp.MapGet($"/tabs/{name}/{{*path}}", (string path) => - { - IFileInfo file = provider.GetFileInfo($"/{path}"); - if (!file.Exists) return Results.NotFound(); - _contentTypeProvider.TryGetContentType(file.Name, out var contentType); - return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); - }); - }); - - return this; - } - - /// - /// Registers an HTTP POST endpoint at /functions/{name} with a typed request body and typed response. - /// The endpoint is mapped when is called. - /// - /// The type to deserialize the JSON request body into. - /// The function name used in the URL path. - /// The async handler whose return value is serialized as the JSON response. - /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( - string name, - Func, CancellationToken, Task> handler) - { - ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); - ArgumentNullException.ThrowIfNull(handler, nameof(handler)); - - _functionActions.Add((webApp, botApp) => - { - webApp.MapPost($"/functions/{name}", async (HttpContext httpCtx, CancellationToken ct) => - { - FunctionRequest request = await httpCtx.Request.ReadFromJsonAsync>(ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("Missing request body."); - FunctionContext ctx = new(botApp) - { - TenantId = httpCtx.User.FindFirst("tid")?.Value, - UserId = httpCtx.User.FindFirst("oid")?.Value, - UserName = httpCtx.User.FindFirst(ClaimTypes.Name)?.Value, - AuthToken = AuthenticationHeaderValue.TryParse(httpCtx.Request.Headers.Authorization.FirstOrDefault(), out var header) ? header.Parameter : null, - TeamsContext = request.Context, - Data = request.Payload, - }; - return Results.Json(await handler(ctx, ct).ConfigureAwait(false)); - }).RequireAuthorization(JwtExtensions.EntraPolicy); - }); - - return this; - } - - /// - /// Registers an HTTP POST endpoint at /functions/{name} with no request body. - /// The endpoint is mapped when is called. - /// - /// The function name used in the URL path. - /// The async handler whose return value is serialized as the JSON response. - /// The current instance for fluent chaining. - public TeamsBotApplicationBuilder WithFunction( - string name, - Func> handler) - => WithFunction(name, (ctx, ct) => handler(ctx, ct)); } diff --git a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs index 1123d6f9..0c675619 100644 --- a/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs +++ b/core/src/Microsoft.Teams.Bot.Core/BotApplication.cs @@ -31,17 +31,14 @@ public class BotApplication /// Options containing the application (client) ID, used for logging and diagnostics. Defaults to an empty instance if not provided. public BotApplication(ConversationClient conversationClient, UserTokenClient userTokenClient, ILogger logger, BotApplicationOptions? options = null) { - Options = options ?? new(); + options ??= new(); _logger = logger; MiddleWare = new TurnMiddleware(); _conversationClient = conversationClient; _userTokenClient = userTokenClient; - logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, Options.AppId, Version); + logger.LogInformation("Started {ThisType} listener for AppID:{AppId} with SDK version {SdkVersion}", this.GetType().Name, options.AppId, Version); } - /// Gets the options this bot application was configured with. - public BotApplicationOptions Options { get; } - /// /// Gets the client used to manage and interact with conversations. diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs index d2e640d5..288ad2db 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -100,11 +100,9 @@ public static IServiceCollection AddBotApplication(this IServiceCollection services.AddSingleton(sp => { IConfiguration config = sp.GetRequiredService(); - string serviceUrlStr = config["SERVICE_URL"] ?? "https://smba.trafficmanager.net/teams"; return new BotApplicationOptions { - AppId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? string.Empty, - ServiceUrl = new Uri(serviceUrlStr.TrimEnd('/')) + AppId = config["MicrosoftAppId"] ?? config["CLIENT_ID"] ?? config[$"{sectionName}:ClientId"] ?? string.Empty }; }); services.AddAuthorization(logger, sectionName); diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs index 92f395d7..5e23f17b 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs @@ -12,11 +12,4 @@ public sealed class BotApplicationOptions /// Gets or sets the application (client) ID, used for logging and diagnostics. /// public string AppId { get; set; } = string.Empty; - - /// - /// Gets or sets the Teams bot service URL used for proactive messaging. - /// Defaults to https://smba.trafficmanager.net/teams if not configured via the - /// SERVICE_URL environment variable or app settings. - /// - public Uri ServiceUrl { get; set; } = new Uri("https://smba.trafficmanager.net/teams"); } From 08ec4277c0e8aa2c4d4e17774d06ab289ceaac55 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Mon, 2 Mar 2026 22:03:25 -0800 Subject: [PATCH 14/20] add full func --- core/samples/TabApp/Body.cs | 1 + core/samples/TabApp/Program.cs | 39 ++++++++++++++++++++++++++++- core/samples/TabApp/Web/src/App.tsx | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs index a7bad304..71fb2941 100644 --- a/core/samples/TabApp/Body.cs +++ b/core/samples/TabApp/Body.cs @@ -6,6 +6,7 @@ namespace TabApp; public class PostToChatBody { public required string Message { get; set; } + public string? ConversationId { get; set; } } public record PostToChatResult(bool Ok); diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index 481212e7..956d6fcd 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -3,11 +3,15 @@ using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; +using Microsoft.Teams.Bot.Core.Schema; using TabApp; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); builder.Services.AddAuthorization(logger: null); +builder.Services.AddConversationClient(); WebApplication app = builder.Build(); app.UseAuthentication(); @@ -34,14 +38,47 @@ return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); }); +// ==================== SERVER FUNCTIONS ==================== app.MapPost("/functions/post-to-chat", async ( PostToChatBody body, HttpContext httpCtx, + ConversationClient conversations, + IConfiguration config, ILogger logger, CancellationToken ct) => { - logger.LogInformation("post-to-chat called by user {UserId}", httpCtx.User.FindFirst("oid")?.Value); + logger.LogInformation("post-to-chat called"); + + var serviceUrl = new Uri("https://smba.trafficmanager.net/teams"); + var conversationId = body.ConversationId; + + if (conversationId is null) + { + var botId = config["CLIENT_ID"] ?? config["MicrosoftAppId"] ?? config["AzureAd:ClientId"] + ?? throw new InvalidOperationException("Bot client ID not configured."); + var userId = httpCtx.User.FindFirst("oid")?.Value + ?? httpCtx.User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value + ?? throw new InvalidOperationException("User OID claim missing."); + var tenantId = httpCtx.User.FindFirst("tid")?.Value + ?? httpCtx.User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; + + var res = await conversations.CreateConversationAsync(new ConversationParameters + { + IsGroup = false, + TenantId = tenantId, + Bot = new ConversationAccount { Id = botId }, + Members = [new ConversationAccount { Id = userId }] + }, serviceUrl, cancellationToken: ct); + + conversationId = res.Id ?? throw new InvalidOperationException("CreateConversation returned no ID."); + } + + MessageActivity activity = new(body.Message); + activity.ServiceUrl = serviceUrl; + activity.Conversation.Id = conversationId; + await conversations.SendActivityAsync(activity, cancellationToken: ct); + return Results.Json(new PostToChatResult(Ok: true)); }).RequireAuthorization("EntraPolicy"); diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index 4f2fc727..4e12a6d1 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -77,7 +77,7 @@ export default function App() { } const showContext = useCallback(() => run(async () => context), [context]) - const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message })), [message]) + const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message, conversationId: context?.chat?.id ?? context?.channel?.id })), [message, context]) const whoAmI = useCallback(() => run(async () => { const accessToken = await acquireToken(['User.Read'], context) return fetch('https://graph.microsoft.com/v1.0/me', { From 10e65e7cbdb85176d66e3f8f8a5b6d14b63e81ae Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Tue, 3 Mar 2026 18:30:01 -0800 Subject: [PATCH 15/20] simplify auth --- core/samples/TabApp/Program.cs | 23 ++-- .../Hosting/AuthenticationSchemeOptions.cs | 20 --- .../Hosting/BotConfig.cs | 24 ++++ .../Hosting/JwtExtensions.cs | 121 +++++++----------- 4 files changed, 78 insertions(+), 110 deletions(-) delete mode 100644 core/src/Microsoft.Teams.Bot.Core/Hosting/AuthenticationSchemeOptions.cs diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index 956d6fcd..ebc6b0ef 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; @@ -19,25 +18,19 @@ // ==================== TABS ==================== -// Serve the React tab at /tabs/test (build the web app first: cd Web && npm install && npm run build) -var tabProvider = new PhysicalFileProvider(Path.GetFullPath("./Web/bin")); -var contentTypeProvider = new FileExtensionContentTypeProvider(); - +// Serve the React build folder app.UseStaticFiles(new StaticFileOptions { - FileProvider = tabProvider, - RequestPath = "/tabs/test", - ServeUnknownFileTypes = true + FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "Web", "build")), + RequestPath = "/tabs/test" }); -app.MapGet("/tabs/test/{*path}", (string path) => +// Fallback to index.html for SPA routing +app.MapFallback("/tabs/test/{*path}", () => { - IFileInfo file = tabProvider.GetFileInfo($"/{path}"); - if (!file.Exists) return Results.NotFound(); - contentTypeProvider.TryGetContentType(file.Name, out var contentType); - return Results.File(file.CreateReadStream(), contentType ?? "application/octet-stream"); + var file = Path.Combine(Directory.GetCurrentDirectory(), "Web", "build", "index.html"); + return Results.File(File.OpenRead(file), "text/html"); }); - // ==================== SERVER FUNCTIONS ==================== app.MapPost("/functions/post-to-chat", async ( @@ -80,6 +73,6 @@ await conversations.SendActivityAsync(activity, cancellationToken: ct); return Results.Json(new PostToChatResult(Ok: true)); -}).RequireAuthorization("EntraPolicy"); +}).RequireAuthorization(); app.Run(); diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AuthenticationSchemeOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AuthenticationSchemeOptions.cs deleted file mode 100644 index 6a120c7a..00000000 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AuthenticationSchemeOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.Teams.Bot.Core.Hosting; - -/// -/// Options for determining which authentication scheme to use. -/// -internal sealed class AuthenticationSchemeOptions -{ - /// - /// Gets or sets a value indicating whether to use Agent authentication (true) or Bot authentication (false). - /// - public bool UseAgentAuth { get; set; } - - /// - /// Gets or sets the scope value used to determine the authentication scheme. - /// - public string Scope { get; set; } = "https://api.botframework.com/.default"; -} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs index 16885dc4..48386ced 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs @@ -105,4 +105,28 @@ public static BotConfig FromAadConfig(IConfiguration configuration, string secti ClientSecret = section["ClientSecret"], }; } + + /// + /// Resolves a BotConfig by trying all configuration formats in priority order: + /// AzureAd section, Core environment variables, then Bot Framework SDK keys. + /// + /// The application configuration. + /// The AAD configuration section name. Defaults to "AzureAd". + /// The first BotConfig with a non-empty ClientId. + /// Thrown when no ClientId is found in any configuration format. + public static BotConfig Resolve(IConfiguration configuration, string sectionName = "AzureAd") + { + ArgumentNullException.ThrowIfNull(configuration); + + BotConfig config = FromAadConfig(configuration, sectionName); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + config = FromCoreConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + config = FromBFConfig(configuration); + if (!string.IsNullOrEmpty(config.ClientId)) return config; + + throw new InvalidOperationException("ClientID not found in configuration."); + } } diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index f53d6601..79212679 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -23,64 +24,67 @@ namespace Microsoft.Teams.Bot.Core.Hosting public static class JwtExtensions { internal const string BotScheme = "BotScheme"; - internal const string AgentScheme = "AgentScheme"; internal const string EntraScheme = "EntraScheme"; - internal const string EntraPolicy = "EntraPolicy"; - internal const string BotScope = "https://api.botframework.com/.default"; - internal const string AgentScope = "https://botapi.skype.com/.default"; + internal const string AutoScheme = "AutoScheme"; internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; - internal const string AgentOIDC = "https://login.microsoftonline.com/"; + internal const string EntraOIDC = "https://login.microsoftonline.com/"; /// /// Adds JWT authentication for bots and agents. /// /// The service collection to add authentication to. - /// The application configuration containing the settings. - /// Indicates whether to use agent authentication (true) or bot authentication (false). /// The configuration section name for the settings. Defaults to "AzureAd". /// The logger instance for logging. /// An for further authentication configuration. - public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, IConfiguration configuration, bool useAgentAuth, ILogger logger, string aadSectionName = "AzureAd") + public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, ILogger logger, string aadSectionName = "AzureAd") { - - // TODO: Task 5039187: Refactor use of BotConfig for MSAL and JWT - AuthenticationBuilder builder = services.AddAuthentication(); - ArgumentNullException.ThrowIfNull(configuration); - string audience = configuration[$"{aadSectionName}:ClientId"] - ?? configuration["CLIENT_ID"] - ?? configuration["MicrosoftAppId"] - ?? throw new InvalidOperationException("ClientID not found in configuration, tried the 3 option"); + ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); + IConfiguration configuration = configDescriptor?.ImplementationInstance as IConfiguration + ?? services.BuildServiceProvider().GetRequiredService(); - string tenantId = configuration[$"{aadSectionName}:TenantId"] - ?? configuration["TENANT_ID"] - ?? configuration["MicrosoftAppTenantId"] - ?? string.Empty; + BotConfig botConfig = BotConfig.Resolve(configuration, aadSectionName); + string audience = botConfig.ClientId; + string tenantId = botConfig.TenantId; - if (!useAgentAuth) - { - string[] validIssuers = ["https://api.botframework.com"]; - builder.AddCustomJwtBearer($"BotScheme_{aadSectionName}", validIssuers, audience, logger); - } - else - { - string agentTenantId = string.IsNullOrEmpty(tenantId) ? "botframework.com" : tenantId; // TODO: Task 5039198: Test JWT Validation for MultiTenant - string[] validIssuers = [$"https://sts.windows.net/{agentTenantId}/", $"https://login.microsoftonline.com/{agentTenantId}/v2", "https://api.botframework.com"]; - builder.AddCustomJwtBearer(AgentScheme, validIssuers, audience, logger); - } + string botSchemeName = $"{BotScheme}_{aadSectionName}"; + string entraSchemeName = $"{EntraScheme}_{aadSectionName}"; + string autoSchemeName = $"{AutoScheme}_{aadSectionName}"; + + string[] botIssuers = ["https://api.botframework.com"]; + builder.AddCustomJwtBearer(botSchemeName, botIssuers, audience, logger); if (string.IsNullOrEmpty(tenantId)) { // Validate dynamically by constructing the expected issuer from the token's tid claim. - builder.AddCustomJwtBearer(EntraScheme, [], audience, logger, ValidateMultiTenantEntraIssuer); + builder.AddCustomJwtBearer(entraSchemeName, [], audience, logger, ValidateMultiTenantEntraIssuer); } else { - string[] entraIssuers = [$"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/"]; - builder.AddCustomJwtBearer(EntraScheme, entraIssuers, audience, logger); + string[] entraIssuers = [ + $"https://login.microsoftonline.com/{tenantId}/v2.0", + $"https://sts.windows.net/{tenantId}/" + ]; + builder.AddCustomJwtBearer(entraSchemeName, entraIssuers, audience, logger); } + // Policy scheme: inspects the token issuer and forwards to the correct scheme. + builder.AddPolicyScheme(autoSchemeName, autoSchemeName, options => + { + options.ForwardDefaultSelector = context => + { + string? auth = context.Request.Headers.Authorization; + if (auth?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true) + { + JwtSecurityToken jwt = new(auth["Bearer ".Length..]); + if (jwt.Issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) + return botSchemeName; + } + return entraSchemeName; + }; + }); + return builder; } @@ -94,50 +98,17 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection public static AuthorizationBuilder AddAuthorization(this IServiceCollection services, ILogger? logger = null, string aadSectionName = "AzureAd") { // Use NullLogger if no logger provided - logger ??= Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - - // We need IConfiguration to determine which authentication scheme to register (Bot vs Agent) - // This is a registration-time decision that cannot be deferred - // Try to get it from service descriptors first (fast path) - ServiceDescriptor? configDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IConfiguration)); - IConfiguration? configuration = configDescriptor?.ImplementationInstance as IConfiguration; - - // If not available as ImplementationInstance, build a temporary ServiceProvider - // NOTE: This is generally an anti-pattern, but acceptable here because: - // 1. We need configuration at registration time to select auth scheme - // 2. We properly dispose the temporary ServiceProvider immediately - // 3. This only happens once during application startup - if (configuration == null) - { - using ServiceProvider tempProvider = services.BuildServiceProvider(); - configuration = tempProvider.GetRequiredService(); - } - - string? azureScope = configuration["Scope"]; - bool useAgentAuth = string.Equals(azureScope, AgentScope, StringComparison.OrdinalIgnoreCase); + logger ??= NullLogger.Instance; - services.AddBotAuthentication(configuration, useAgentAuth, logger, aadSectionName); + services.AddBotAuthentication(logger, aadSectionName); - AuthorizationBuilder authorizationBuilder = services + return services .AddAuthorizationBuilder() .AddDefaultPolicy(aadSectionName, policy => { - if (!useAgentAuth) - { - policy.AuthenticationSchemes.Add($"BotScheme_{aadSectionName}"); - } - else - { - policy.AuthenticationSchemes.Add(AgentScheme); - } - policy.RequireAuthenticatedUser(); - }) - .AddPolicy(EntraPolicy, policy => - { - policy.AddAuthenticationSchemes(EntraScheme); + policy.AuthenticationSchemes.Add($"{AutoScheme}_{aadSectionName}"); policy.RequireAuthenticatedUser(); }); - return authorizationBuilder; } private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => token switch @@ -183,7 +154,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) ? BotOIDC - : $"{AgentOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; + : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; ConfigurationManager manager = configManagerCache.GetOrAdd(authority, a => new ConfigurationManager( @@ -205,7 +176,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") ?? logger - ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + ?? NullLogger.Instance; requestLogger.LogDebug("OnMessageReceived invoked for scheme: {Scheme}", schemeName); string authorizationHeader = context.Request.Headers.Authorization.ToString(); @@ -233,7 +204,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") ?? logger - ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + ?? NullLogger.Instance; requestLogger.LogInformation("Token validated successfully for scheme: {Scheme}", schemeName); return Task.CompletedTask; @@ -244,7 +215,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") ?? logger - ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + ?? NullLogger.Instance; requestLogger.LogWarning("Forbidden response for scheme: {Scheme}", schemeName); return Task.CompletedTask; From d19a6f779ca4f6ccf47c762fbb9269c754275f54 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Tue, 3 Mar 2026 21:17:09 -0800 Subject: [PATCH 16/20] fix sample --- core/samples/TabApp/Body.cs | 3 +- core/samples/TabApp/Program.cs | 78 +++++++++++-------- core/samples/TabApp/Web/src/App.tsx | 2 +- .../Hosting/JwtExtensions.cs | 1 + 4 files changed, 50 insertions(+), 34 deletions(-) diff --git a/core/samples/TabApp/Body.cs b/core/samples/TabApp/Body.cs index 71fb2941..c36c6cde 100644 --- a/core/samples/TabApp/Body.cs +++ b/core/samples/TabApp/Body.cs @@ -6,7 +6,8 @@ namespace TabApp; public class PostToChatBody { public required string Message { get; set; } - public string? ConversationId { get; set; } + public string? ChatId { get; set; } + public string? ChannelId { get; set; } } public record PostToChatResult(bool Ok); diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index ebc6b0ef..ed8b083f 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Extensions.FileProviders; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Identity.Web; using Microsoft.Teams.Bot.Apps.Schema; using Microsoft.Teams.Bot.Core; using Microsoft.Teams.Bot.Core.Hosting; -using Microsoft.Teams.Bot.Core.Schema; using TabApp; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); @@ -18,19 +19,15 @@ // ==================== TABS ==================== -// Serve the React build folder -app.UseStaticFiles(new StaticFileOptions +var contentTypes = new FileExtensionContentTypeProvider(); +app.MapGet("/tabs/test/{*path}", (string? path) => { - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "Web", "build")), - RequestPath = "/tabs/test" + var root = Path.Combine(Directory.GetCurrentDirectory(), "Web", "bin"); + var full = Path.Combine(root, path ?? "index.html"); + contentTypes.TryGetContentType(full, out var ct); + return Results.File(File.OpenRead(full), ct ?? "text/html"); }); -// Fallback to index.html for SPA routing -app.MapFallback("/tabs/test/{*path}", () => -{ - var file = Path.Combine(Directory.GetCurrentDirectory(), "Web", "build", "index.html"); - return Results.File(File.OpenRead(file), "text/html"); -}); // ==================== SERVER FUNCTIONS ==================== app.MapPost("/functions/post-to-chat", async ( @@ -38,38 +35,55 @@ HttpContext httpCtx, ConversationClient conversations, IConfiguration config, + IMemoryCache cache, ILogger logger, CancellationToken ct) => { logger.LogInformation("post-to-chat called"); var serviceUrl = new Uri("https://smba.trafficmanager.net/teams"); - var conversationId = body.ConversationId; + string conversationId; - if (conversationId is null) + if (body.ChatId is not null) { - var botId = config["CLIENT_ID"] ?? config["MicrosoftAppId"] ?? config["AzureAd:ClientId"] - ?? throw new InvalidOperationException("Bot client ID not configured."); - var userId = httpCtx.User.FindFirst("oid")?.Value - ?? httpCtx.User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value - ?? throw new InvalidOperationException("User OID claim missing."); - var tenantId = httpCtx.User.FindFirst("tid")?.Value - ?? httpCtx.User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; - - var res = await conversations.CreateConversationAsync(new ConversationParameters + // group chat or 1:1 chat tab — chat ID is the conversation ID + conversationId = body.ChatId; + } + else if (body.ChannelId is not null) + { + // channel tab — post to the channel directly + conversationId = body.ChannelId; + } + else + { + // personal tab — create or reuse a 1:1 conversation + string userId = httpCtx.User.GetObjectId() ?? throw new InvalidOperationException("User object ID claim not found."); + + if (!cache.TryGetValue($"conv:{userId}", out string? cached)) { - IsGroup = false, - TenantId = tenantId, - Bot = new ConversationAccount { Id = botId }, - Members = [new ConversationAccount { Id = userId }] - }, serviceUrl, cancellationToken: ct); + string botId = config["AzureAd:ClientId"] ?? throw new InvalidOperationException("Bot client ID not configured."); + string tenantId = httpCtx.User.GetTenantId() ?? throw new InvalidOperationException("Tenant ID claim not found."); + + CreateConversationResponse res = await conversations.CreateConversationAsync(new ConversationParameters + { + IsGroup = false, + TenantId = tenantId, + Members = [new TeamsConversationAccount { Id = userId }] + }, serviceUrl, cancellationToken: ct); + + cached = res.Id ?? throw new InvalidOperationException("CreateConversation returned no ID."); + cache.Set($"conv:{userId}", cached); + } - conversationId = res.Id ?? throw new InvalidOperationException("CreateConversation returned no ID."); + conversationId = cached!; } - MessageActivity activity = new(body.Message); - activity.ServiceUrl = serviceUrl; - activity.Conversation.Id = conversationId; + TeamsActivity activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Hello from the tab!") + .WithServiceUrl(serviceUrl) + .WithConversation(new TeamsConversation { Id = conversationId! }) + .Build(); await conversations.SendActivityAsync(activity, cancellationToken: ct); return Results.Json(new PostToChatResult(Ok: true)); diff --git a/core/samples/TabApp/Web/src/App.tsx b/core/samples/TabApp/Web/src/App.tsx index 4e12a6d1..76080247 100644 --- a/core/samples/TabApp/Web/src/App.tsx +++ b/core/samples/TabApp/Web/src/App.tsx @@ -77,7 +77,7 @@ export default function App() { } const showContext = useCallback(() => run(async () => context), [context]) - const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message, conversationId: context?.chat?.id ?? context?.channel?.id })), [message, context]) + const postToChat = useCallback(() => run(() => callFunction('post-to-chat', { message, chatId: context?.chat?.id, channelId: context?.channel?.id })), [message, context]) const whoAmI = useCallback(() => run(async () => { const accessToken = await acquireToken(['User.Read'], context) return fetch('https://graph.microsoft.com/v1.0/me', { diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 79212679..fdb19491 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -95,6 +95,7 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection /// The configuration section name for the settings. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. + // TODO :: confusing with aspnet addAuthorization - rename to AddBotAuthorization or similar public static AuthorizationBuilder AddAuthorization(this IServiceCollection services, ILogger? logger = null, string aadSectionName = "AzureAd") { // Use NullLogger if no logger provided From 2c6e493e942ec5236f1200cca4c9fd2691430688 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Tue, 3 Mar 2026 21:48:30 -0800 Subject: [PATCH 17/20] cleanup auth --- .../Hosting/JwtExtensions.cs | 209 +++++------------- 1 file changed, 52 insertions(+), 157 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index fdb19491..2e1270f5 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -2,10 +2,10 @@ // Licensed under the MIT License. using System.Collections.Concurrent; -using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -23,9 +23,7 @@ namespace Microsoft.Teams.Bot.Core.Hosting /// public static class JwtExtensions { - internal const string BotScheme = "BotScheme"; - internal const string EntraScheme = "EntraScheme"; - internal const string AutoScheme = "AutoScheme"; + internal const string TeamsScheme = "TeamsScheme"; internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; internal const string EntraOIDC = "https://login.microsoftonline.com/"; @@ -45,45 +43,9 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection ?? services.BuildServiceProvider().GetRequiredService(); BotConfig botConfig = BotConfig.Resolve(configuration, aadSectionName); - string audience = botConfig.ClientId; - string tenantId = botConfig.TenantId; - string botSchemeName = $"{BotScheme}_{aadSectionName}"; - string entraSchemeName = $"{EntraScheme}_{aadSectionName}"; - string autoSchemeName = $"{AutoScheme}_{aadSectionName}"; - - string[] botIssuers = ["https://api.botframework.com"]; - builder.AddCustomJwtBearer(botSchemeName, botIssuers, audience, logger); - - if (string.IsNullOrEmpty(tenantId)) - { - // Validate dynamically by constructing the expected issuer from the token's tid claim. - builder.AddCustomJwtBearer(entraSchemeName, [], audience, logger, ValidateMultiTenantEntraIssuer); - } - else - { - string[] entraIssuers = [ - $"https://login.microsoftonline.com/{tenantId}/v2.0", - $"https://sts.windows.net/{tenantId}/" - ]; - builder.AddCustomJwtBearer(entraSchemeName, entraIssuers, audience, logger); - } - - // Policy scheme: inspects the token issuer and forwards to the correct scheme. - builder.AddPolicyScheme(autoSchemeName, autoSchemeName, options => - { - options.ForwardDefaultSelector = context => - { - string? auth = context.Request.Headers.Authorization; - if (auth?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true) - { - JwtSecurityToken jwt = new(auth["Bearer ".Length..]); - if (jwt.Issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) - return botSchemeName; - } - return entraSchemeName; - }; - }); + string schemeName = $"{TeamsScheme}_{aadSectionName}"; + builder.AddTeamsJwtBearer(schemeName, botConfig.ClientId, botConfig.TenantId, logger); return builder; } @@ -95,10 +57,8 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection /// The configuration section name for the settings. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. - // TODO :: confusing with aspnet addAuthorization - rename to AddBotAuthorization or similar public static AuthorizationBuilder AddAuthorization(this IServiceCollection services, ILogger? logger = null, string aadSectionName = "AzureAd") { - // Use NullLogger if no logger provided logger ??= NullLogger.Instance; services.AddBotAuthentication(logger, aadSectionName); @@ -107,30 +67,36 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv .AddAuthorizationBuilder() .AddDefaultPolicy(aadSectionName, policy => { - policy.AuthenticationSchemes.Add($"{AutoScheme}_{aadSectionName}"); + policy.AuthenticationSchemes.Add($"{TeamsScheme}_{aadSectionName}"); policy.RequireAuthenticatedUser(); }); } - private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => token switch + private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId) { - JsonWebToken jwt => (jwt.Issuer, jwt.TryGetClaim("tid", out var c) ? c.Value : null), - JwtSecurityToken legacy => (legacy.Issuer, legacy.Claims.FirstOrDefault(c => c.Type == "tid")?.Value), - _ => (null, null) - }; + // Bot Framework tokens + if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) + return issuer; - private static string ValidateMultiTenantEntraIssuer(string issuer, SecurityToken token, TokenValidationParameters parameters) - { - (string? _, string? tid) = GetTokenClaims(token); - if (tid != null && - (issuer == $"https://login.microsoftonline.com/{tid}/v2.0" || - issuer == $"https://sts.windows.net/{tid}/")) + // Entra tokens — bot-to-bot (agent) and user (tab/API) + // Use the token's own tid claim for multi-tenant; fall back to configured tenant + (_, string? tid) = GetTokenClaims(token); + string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId; + + if (effectiveTenant is not null && + (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" || + issuer == $"https://sts.windows.net/{effectiveTenant}/")) return issuer; - throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid for multi-tenant Entra authentication."); + throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid."); } - private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuilder builder, string schemeName, string[] validIssuers, string audience, ILogger? logger, IssuerValidator? issuerValidator = null) + private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) => + token is JsonWebToken jwt + ? (jwt.Issuer, jwt.TryGetClaim("tid", out var c) ? c.Value : null) + : (null, null); + + private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger) { // One ConfigurationManager per OIDC authority, shared safely across all requests. ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); @@ -146,8 +112,7 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild RequireSignedTokens = true, ValidateIssuer = true, ValidateAudience = true, - ValidIssuers = issuerValidator is null ? validIssuers : null, - IssuerValidator = issuerValidator, + IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId), IssuerSigningKeyResolver = (_, securityToken, _, _) => { (string? iss, string? tid) = GetTokenClaims(securityToken); @@ -171,122 +136,47 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild jwtOptions.MapInboundClaims = true; jwtOptions.Events = new JwtBearerEvents { - OnMessageReceived = async context => - { - // Resolve logger at runtime from request services to ensure we always have proper logging - ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); - ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") - ?? logger - ?? NullLogger.Instance; - - requestLogger.LogDebug("OnMessageReceived invoked for scheme: {Scheme}", schemeName); - string authorizationHeader = context.Request.Headers.Authorization.ToString(); - - if (string.IsNullOrEmpty(authorizationHeader)) - { - requestLogger.LogWarning("Authorization header is missing for scheme: {Scheme}", schemeName); - await Task.CompletedTask.ConfigureAwait(false); - return; - } - - string[] parts = authorizationHeader.Split(' '); - if (parts.Length != 2 || parts[0] != "Bearer") - { - requestLogger.LogWarning("Invalid authorization header format for scheme: {Scheme}", schemeName); - await Task.CompletedTask.ConfigureAwait(false); - return; - } - - await Task.CompletedTask.ConfigureAwait(false); - }, OnTokenValidated = context => { - // Resolve logger at runtime - ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); - ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") - ?? logger - ?? NullLogger.Instance; - - requestLogger.LogInformation("Token validated successfully for scheme: {Scheme}", schemeName); + GetLogger(context.HttpContext, logger).LogDebug("Token validated for scheme: {Scheme}", schemeName); return Task.CompletedTask; }, OnForbidden = context => { - // Resolve logger at runtime - ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); - ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") - ?? logger - ?? NullLogger.Instance; - - requestLogger.LogWarning("Forbidden response for scheme: {Scheme}", schemeName); + GetLogger(context.HttpContext, logger).LogWarning("Forbidden for scheme: {Scheme}", schemeName); return Task.CompletedTask; }, OnAuthenticationFailed = context => { - // Resolve logger at runtime to ensure authentication failures are always logged - ILoggerFactory? loggerFactory = context.HttpContext.RequestServices.GetService(); - ILogger requestLogger = loggerFactory?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") - ?? logger - ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + ILogger log = GetLogger(context.HttpContext, logger); - // Extract detailed information for troubleshooting - string? tokenAudience = null; - string? tokenIssuer = null; - string? tokenExpiration = null; - string? tokenSubject = null; - - try + string? tokenIssuer = null, tokenAudience = null, tokenExpiration = null, tokenSubject = null; + string authHeader = context.Request.Headers.Authorization.ToString(); + if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - // Try to parse the token to extract claims - string authHeader = context.Request.Headers.Authorization.ToString(); - if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + try { - string tokenString = authHeader.Substring("Bearer ".Length).Trim(); - JwtSecurityToken token = new(tokenString); - - tokenAudience = token.Audiences?.FirstOrDefault(); - tokenIssuer = token.Issuer; - tokenExpiration = token.ValidTo.ToString("o"); - tokenSubject = token.Subject; + JsonWebToken jwt = new(authHeader["Bearer ".Length..].Trim()); + (tokenIssuer, _) = GetTokenClaims(jwt); + tokenAudience = jwt.GetClaim("aud")?.Value; + tokenExpiration = jwt.ValidTo.ToString("o"); + tokenSubject = jwt.Subject; } + catch (ArgumentException) { } } -#pragma warning disable CA1031 // Do not catch general exception types - we want to continue logging even if token parsing fails - catch - { - // If we can't parse the token, continue with logging the exception - } -#pragma warning restore CA1031 - // Get configured validation parameters TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters; - string configuredAudience = validationParams?.ValidAudience ?? "null"; - string configuredAudiences = validationParams?.ValidAudiences != null - ? string.Join(", ", validationParams.ValidAudiences) - : "null"; - string configuredIssuers = validationParams?.ValidIssuers != null - ? string.Join(", ", validationParams.ValidIssuers) - : "null"; - - // Log detailed failure information - requestLogger.LogError(context.Exception, - "JWT Authentication failed for scheme: {Scheme}\n" + - " Failure Reason: {ExceptionMessage}\n" + - " Token Audience: {TokenAudience}\n" + - " Expected Audience: {ConfiguredAudience}\n" + - " Expected Audiences: {ConfiguredAudiences}\n" + - " Token Issuer: {TokenIssuer}\n" + - " Valid Issuers: {ConfiguredIssuers}\n" + - " Token Expiration: {TokenExpiration}\n" + - " Token Subject: {TokenSubject}", + log.LogError(context.Exception, + "JWT authentication failed for scheme {Scheme}: {ExceptionMessage} | " + + "token iss={TokenIssuer} aud={TokenAudience} exp={TokenExpiration} sub={TokenSubject} | " + + "expected aud={ConfiguredAudience}", schemeName, context.Exception.Message, - tokenAudience ?? "Unable to parse", - configuredAudience, - configuredAudiences, - tokenIssuer ?? "Unable to parse", - configuredIssuers, - tokenExpiration ?? "Unable to parse", - tokenSubject ?? "Unable to parse"); + tokenIssuer ?? "n/a", + tokenAudience ?? "n/a", + tokenExpiration ?? "n/a", + tokenSubject ?? "n/a", + validationParams?.ValidAudience ?? "n/a"); return Task.CompletedTask; } @@ -295,5 +185,10 @@ private static AuthenticationBuilder AddCustomJwtBearer(this AuthenticationBuild }); return builder; } + + private static ILogger GetLogger(HttpContext context, ILogger? fallback) => + context.RequestServices.GetService()?.CreateLogger(typeof(JwtExtensions).FullName ?? "JwtExtensions") + ?? fallback + ?? NullLogger.Instance; } } From c8d793e45751fe489e6ddd52d3c765f2e9da6cd7 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Tue, 3 Mar 2026 21:53:05 -0800 Subject: [PATCH 18/20] add todo --- core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 2e1270f5..63d57558 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -57,6 +57,7 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection /// The configuration section name for the settings. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. /// An for further authorization configuration. + // TODO : Rename to AddBotAuthorization public static AuthorizationBuilder AddAuthorization(this IServiceCollection services, ILogger? logger = null, string aadSectionName = "AzureAd") { logger ??= NullLogger.Instance; From 37509c22063419a8b7f44f448c0aafcbf279983f Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Mon, 9 Mar 2026 14:03:18 -0700 Subject: [PATCH 19/20] small fix --- core/samples/TabApp/README.md | 10 +++++++--- .../Hosting/JwtExtensions.cs | 18 +++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/core/samples/TabApp/README.md b/core/samples/TabApp/README.md index f3e25c10..30b6ba7b 100644 --- a/core/samples/TabApp/README.md +++ b/core/samples/TabApp/README.md @@ -19,7 +19,7 @@ A sample demonstrating a React/Vite tab served by the bot, with server functions Under **Expose an API → Application ID URI**, set it to: ``` -api://{YOUR_DOMAIN}/{YOUR_CLIENT_ID} +api://{YOUR_CLIENT_ID} ``` Then add a scope named `access_as_user` and pre-authorize the Teams client IDs: @@ -36,6 +36,10 @@ Under **Authentication → Add a platform → Single-page application**, add: ``` https://{YOUR_DOMAIN}/tabs/test ``` +and +``` +brk-multihub://{your_domain} +``` ### 3. API permissions @@ -55,7 +59,7 @@ Under **API permissions → Add a permission → Microsoft Graph → Delegated** ```json "webApplicationInfo": { "id": "{YOUR_CLIENT_ID}", - "resource": "api://{YOUR_DOMAIN}/{YOUR_CLIENT_ID}" + "resource": "api://{YOUR_CLIENT_ID}" } ``` @@ -86,7 +90,7 @@ Under **API permissions → Add a permission → Microsoft Graph → Delegated** "AzureAd__ClientCredentials__0__ClientSecret": "{YOUR_CLIENT_SECRET}" ``` -**`Web/.env.local`**: +**`Web/.env`**: ``` VITE_CLIENT_ID={YOUR_CLIENT_ID} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 63d57558..e124dddd 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -23,7 +23,6 @@ namespace Microsoft.Teams.Bot.Core.Hosting /// public static class JwtExtensions { - internal const string TeamsScheme = "TeamsScheme"; internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration"; internal const string EntraOIDC = "https://login.microsoftonline.com/"; @@ -44,8 +43,7 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection BotConfig botConfig = BotConfig.Resolve(configuration, aadSectionName); - string schemeName = $"{TeamsScheme}_{aadSectionName}"; - builder.AddTeamsJwtBearer(schemeName, botConfig.ClientId, botConfig.TenantId, logger); + builder.AddTeamsJwtBearer(aadSectionName, botConfig.ClientId, botConfig.TenantId, logger); return builder; } @@ -68,7 +66,7 @@ public static AuthorizationBuilder AddAuthorization(this IServiceCollection serv .AddAuthorizationBuilder() .AddDefaultPolicy(aadSectionName, policy => { - policy.AuthenticationSchemes.Add($"{TeamsScheme}_{aadSectionName}"); + policy.AuthenticationSchemes.Add(aadSectionName); policy.RequireAuthenticatedUser(); }); } @@ -106,13 +104,13 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde { jwtOptions.SaveToken = true; jwtOptions.IncludeErrorDetails = true; - jwtOptions.Audience = audience; jwtOptions.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, RequireSignedTokens = true, ValidateIssuer = true, ValidateAudience = true, + ValidAudiences = [audience, $"api://{audience}"], IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId), IssuerSigningKeyResolver = (_, securityToken, _, _) => { @@ -151,7 +149,10 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde { ILogger log = GetLogger(context.HttpContext, logger); - string? tokenIssuer = null, tokenAudience = null, tokenExpiration = null, tokenSubject = null; + string? tokenIssuer = null; + string? tokenAudience = null; + string? tokenExpiration = null; + string? tokenSubject = null; string authHeader = context.Request.Headers.Authorization.ToString(); if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { @@ -167,6 +168,9 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde } TokenValidationParameters? validationParams = context.Options?.TokenValidationParameters; + string expectedAudiences = validationParams?.ValidAudiences is not null + ? string.Join(", ", validationParams.ValidAudiences) + : validationParams?.ValidAudience ?? "n/a"; log.LogError(context.Exception, "JWT authentication failed for scheme {Scheme}: {ExceptionMessage} | " + "token iss={TokenIssuer} aud={TokenAudience} exp={TokenExpiration} sub={TokenSubject} | " + @@ -177,7 +181,7 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde tokenAudience ?? "n/a", tokenExpiration ?? "n/a", tokenSubject ?? "n/a", - validationParams?.ValidAudience ?? "n/a"); + expectedAudiences); return Task.CompletedTask; } From 8fc68b96b27728de7a5085869e6878e6341af290 Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Mon, 9 Mar 2026 14:12:10 -0700 Subject: [PATCH 20/20] fix --- core/samples/TabApp/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/samples/TabApp/Program.cs b/core/samples/TabApp/Program.cs index ed8b083f..820b7c1e 100644 --- a/core/samples/TabApp/Program.cs +++ b/core/samples/TabApp/Program.cs @@ -10,7 +10,7 @@ using TabApp; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); -builder.Services.AddAuthorization(logger: null); +builder.Services.AddBotAuthorization(); builder.Services.AddConversationClient(); WebApplication app = builder.Build();