├── .github ├── CODEOWNERS ├── dependabot.yml ├── workflows │ ├── dotnetcore.yml │ ├── auto-merge-dependabot.yml │ └── codeql.yml ├── ISSUE_TEMPLATE │ ├── ask-a-question.md │ └── bug_report.md └── policies │ └── resourceManagement.yml ├── images ├── add-access-policy.png ├── copy-secret-value.png ├── create-a-resource.png ├── ngrok-https-url.png ├── register-an-app.png ├── user-inbox-notifications.png ├── teams-channel-notifications.png └── remove-configured-permission.png ├── src └── GraphWebhooks │ ├── wwwroot │ ├── css │ │ └── site.css │ ├── img │ │ └── g-raph.png │ ├── js │ │ └── site.js │ └── lib │ │ ├── jquery-validation-unobtrusive │ │ ├── LICENSE.txt │ │ ├── jquery.validate.unobtrusive.min.js │ │ └── jquery.validate.unobtrusive.js │ │ ├── jquery-validation │ │ ├── LICENSE.md │ │ └── dist │ │ │ └── additional-methods.min.js │ │ ├── bootstrap │ │ ├── LICENSE │ │ └── dist │ │ │ └── css │ │ │ ├── bootstrap-reboot.min.css │ │ │ ├── bootstrap-reboot.rtl.min.css │ │ │ ├── bootstrap-reboot.rtl.css │ │ │ └── bootstrap-reboot.css │ │ └── jquery │ │ └── LICENSE.txt │ ├── Views │ ├── _ViewStart.cshtml │ ├── _ViewImports.cshtml │ ├── Shared │ │ ├── _ValidationScriptsPartial.cshtml │ │ ├── _AlertPartial.cshtml │ │ ├── Error.cshtml │ │ └── _Layout.cshtml │ ├── Home │ │ └── Index.cshtml │ └── Watch │ │ ├── AppOnly.cshtml │ │ └── Delegated.cshtml │ ├── appsettings.Development.json │ ├── Areas │ └── MicrosoftIdentity │ │ └── Pages │ │ └── Account │ │ ├── SignedOut.cshtml │ │ └── SignedOut.cshtml.cs │ ├── SignalR │ └── NotificationHub.cs │ ├── Models │ ├── ClientNotification.cs │ ├── ErrorViewModel.cs │ └── SubscriptionRecord.cs │ ├── stylecop.json │ ├── Graph │ ├── GraphClaimTypes.cs │ └── GraphClaimsPrincipalExtensions.cs │ ├── appsettings.json │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Controllers │ ├── HomeController.cs │ ├── LifecycleController.cs │ ├── ListenController.cs │ └── WatchController.cs │ ├── GraphWebhooks.csproj │ ├── Services │ ├── SubscriptionStore.cs │ └── CertificateService.cs │ ├── Alerts │ ├── WithAlertResult.cs │ └── AlertExtensions.cs │ └── Startup.cs ├── .editorconfig ├── .markdownlint.json ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── aspnetcore-webhooks-sample.sln ├── KEYVAULT.md ├── .gitattributes ├── SECURITY.md ├── TROUBLESHOOTING.md ├── .gitignore └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @microsoftgraph/msgraph-devx-samples-write -------------------------------------------------------------------------------- /images/add-access-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/add-access-policy.png -------------------------------------------------------------------------------- /images/copy-secret-value.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/copy-secret-value.png -------------------------------------------------------------------------------- /images/create-a-resource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/create-a-resource.png -------------------------------------------------------------------------------- /images/ngrok-https-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/ngrok-https-url.png -------------------------------------------------------------------------------- /images/register-an-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/register-an-app.png -------------------------------------------------------------------------------- /images/user-inbox-notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/user-inbox-notifications.png -------------------------------------------------------------------------------- /images/teams-channel-notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/teams-channel-notifications.png -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/css/site.css: -------------------------------------------------------------------------------- 1 | .wrapped-pre { 2 | word-wrap: break-word; 3 | word-break: break-all; 4 | white-space: pre-wrap; 5 | } 6 | -------------------------------------------------------------------------------- /images/remove-configured-permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/images/remove-configured-permission.png -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/img/g-raph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/aspnetcore-webhooks-sample/HEAD/src/GraphWebhooks/wwwroot/img/g-raph.png -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @{ 5 | Layout = "_Layout"; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | insert_final_newline = true 5 | 6 | [*.{cs,cshtml}] 7 | indent_size = 4 8 | dotnet_diagnostic.SA1101.severity = silent 9 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/master/schema/markdownlint-config-schema.json", 4 | "no-trailing-punctuation": false, 5 | "MD013": false 6 | } 7 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @using GraphWebhooks 5 | @using GraphWebhooks.Models 6 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 7 | -------------------------------------------------------------------------------- /src/GraphWebhooks/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/js/site.js: -------------------------------------------------------------------------------- 1 | // Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification 2 | // for details on configuring this project to bundle and minify static web assets. 3 | 4 | // Write your JavaScript code. 5 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Shared/_ValidationScriptsPartial.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: /src/GraphWebhooks/ 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Areas/MicrosoftIdentity/Pages/Account/SignedOut.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | 4 | 5 | @model GraphWebhooks.Areas.MicrosoftIdentity.Pages.Account.SignedOut 6 | @{ 7 | ViewData["Title"] = "Signed out"; 8 | } 9 | 10 |

@ViewData["Title"]

11 |

12 | You have successfully signed out. 13 |

14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.ignoreWords": [ 3 | "signin-oidc" 4 | ], 5 | "cSpell.words": [ 6 | "apponly", 7 | "appsettings", 8 | "Decryptable", 9 | "Decryptor", 10 | "Encryptable", 11 | "entra", 12 | "HMACSHA", 13 | "HSTS", 14 | "mailfolders", 15 | "Msal", 16 | "Multitenant", 17 | "Oaep", 18 | "PKCS", 19 | "signin", 20 | "signout", 21 | "SYSLIB", 22 | "utid" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/GraphWebhooks/SignalR/NotificationHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using Microsoft.AspNetCore.SignalR; 5 | 6 | namespace GraphWebhooks.SignalR; 7 | 8 | /// 9 | /// An implementation of the Hub class, used to initialize 10 | /// a HubContext. This class does nothing since the app is 11 | /// not receiving SignalR notifications, only sending. 12 | /// 13 | public class NotificationHub : Hub 14 | { 15 | } 16 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Models/ClientNotification.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | namespace GraphWebhooks.Models; 5 | 6 | /// 7 | /// Payload sent via SignalR to listening clients. 8 | /// 9 | public class ClientNotification(object notificationValue) 10 | { 11 | /// 12 | /// Gets the resource that triggered the notification. 13 | /// 14 | public object Resource { get; } = notificationValue; 15 | } 16 | -------------------------------------------------------------------------------- /src/GraphWebhooks/stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "companyName": "Microsoft Corporation", 6 | "copyrightText": "Copyright (c) {companyName}.\nLicensed under the MIT license.", 7 | "xmlHeader": false 8 | }, 9 | "orderingRules": { 10 | "usingDirectivesPlacement": "outsideNamespace" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Graph/GraphClaimTypes.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | namespace GraphWebhooks; 5 | 6 | /// 7 | /// The available claim types. 8 | /// 9 | public static class GraphClaimTypes 10 | { 11 | /// 12 | /// The display name claim. 13 | /// 14 | public const string DisplayName = "graph_name"; 15 | 16 | /// 17 | /// The email claim. 18 | /// 19 | public const string Email = "graph_email"; 20 | } 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | - Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) 11 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Models/ErrorViewModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | namespace GraphWebhooks.Models; 5 | 6 | /// 7 | /// View model used by the error page. 8 | /// 9 | public class ErrorViewModel 10 | { 11 | /// 12 | /// Gets or sets the request ID. 13 | /// 14 | public string? RequestId { get; set; } 15 | 16 | /// 17 | /// Gets a value indicating whether to show the request ID. 18 | /// 19 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 20 | } 21 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) .NET Foundation. All rights reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 4 | these files except in compliance with the License. You may obtain a copy of the 5 | License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software distributed 10 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 11 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 12 | specific language governing permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | defaults: 10 | run: 11 | working-directory: src/GraphWebhooks 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup .NET Core 21 | uses: actions/setup-dotnet@v4 22 | with: 23 | dotnet-version: 8.x 24 | - name: Install dependencies 25 | run: dotnet restore 26 | - name: Build 27 | run: dotnet build --configuration Release --no-restore 28 | - name: Test 29 | run: dotnet test --no-restore --verbosity normal 30 | -------------------------------------------------------------------------------- /src/GraphWebhooks/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AzureAd": { 3 | "TenantId": "YOUR_TENANT_ID", 4 | "ClientId": "YOUR_CLIENT_ID", 5 | "ClientSecret": "YOUR_CLIENT_SECRET", 6 | "Instance": "https://login.microsoftonline.com/", 7 | "CallbackPath": "/signin-oidc" 8 | }, 9 | "GraphScopes": "User.Read Mail.Read", 10 | "NotificationHost": "YOUR_NGROK_PROXY", 11 | "KeyVault": { 12 | "Url": "YOUR_KEYVAULT_URL", 13 | "CertificateName": "YOUR_CERTIFICATE_NAME" 14 | }, 15 | "Logging": { 16 | "LogLevel": { 17 | "Default": "Information", 18 | "Microsoft": "Warning", 19 | "Microsoft.Hosting.Lifetime": "Information" 20 | } 21 | }, 22 | "AllowedHosts": "*" 23 | } 24 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | namespace GraphWebhooks; 5 | 6 | /// 7 | /// Contains the main entry point of the app. 8 | /// 9 | public class Program 10 | { 11 | /// 12 | /// The main entry point of the app. 13 | /// 14 | /// Command line arguments. 15 | public static void Main(string[] args) 16 | { 17 | CreateHostBuilder(args).Build().Run(); 18 | } 19 | 20 | private static IHostBuilder CreateHostBuilder(string[] args) => 21 | Host.CreateDefaultBuilder(args) 22 | .ConfigureWebHostDefaults(webBuilder => 23 | { 24 | webBuilder.UseStartup(); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:59444", 7 | "sslPort": 44354 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "GraphWebhooks": { 19 | "commandName": "Project", 20 | "dotnetRunMessages": "true", 21 | "launchBrowser": true, 22 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ask-a-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask a question 3 | about: Ask a question about Graph, adding features to this sample, etc. 4 | title: '' 5 | labels: question, needs triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | Thank you for taking an interest in Microsoft Graph development! Please feel free to ask a question here, but keep in mind the following: 11 | 12 | - This is not an official Microsoft support channel, and our ability to respond to questions here is limited. Questions about Graph, or questions about adding a new feature to the sample, will be answered on a best-effort basis. 13 | - Questions about Microsoft Graph should be asked on [Microsoft Q&A](https://docs.microsoft.com/answers/products/graph). 14 | - Issues with Microsoft Graph itself should be handled through [support](https://developer.microsoft.com/graph/support). 15 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Shared/_AlertPartial.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @{ 5 | var type = $"{TempData["_alertType"]}"; 6 | var message = $"{TempData["_alertMessage"]}"; 7 | var debugInfo = $"{TempData["_alertDebugInfo"]}"; 8 | } 9 | 10 | @if (!string.IsNullOrEmpty(type)) 11 | { 12 | 24 | } 25 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Areas/MicrosoftIdentity/Pages/Account/SignedOut.cshtml.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | 8 | namespace GraphWebhooks.Areas.MicrosoftIdentity.Pages.Account; 9 | 10 | /// 11 | /// Model for the SignOut page. Overrides the SignedOut.cshtml 12 | /// page exported by Microsoft.Identity.Web.UI 13 | /// to allow redirecting to home page even if not authenticated. 14 | /// 15 | [AllowAnonymous] 16 | public class SignedOut : PageModel 17 | { 18 | /// 19 | /// Method handling the HTTP GET method. 20 | /// 21 | /// Redirect to Home page. 22 | public IActionResult OnGet() 23 | { 24 | return LocalRedirect("~/"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Models/SubscriptionRecord.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | namespace GraphWebhooks.Models; 5 | 6 | /// 7 | /// Subscription record saved in subscription store. 8 | /// 9 | public record SubscriptionRecord 10 | { 11 | /// 12 | /// Gets or sets the ID of the subscription. 13 | /// 14 | public string? Id { get; set; } 15 | 16 | /// 17 | /// Gets or sets the user's ID associated with the subscription. 18 | /// 19 | public string? UserId { get; set; } 20 | 21 | /// 22 | /// Gets or sets the tenant ID of the organization. 23 | /// 24 | public string? TenantId { get; set; } 25 | 26 | /// 27 | /// Gets or sets the client state set in the subscription. 28 | /// 29 | public string? ClientState { get; set; } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Auto-merge dependabot updates 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | permissions: 8 | pull-requests: write 9 | contents: write 10 | 11 | jobs: 12 | 13 | dependabot-merge: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | if: ${{ github.actor == 'dependabot[bot]' }} 18 | 19 | steps: 20 | - name: Dependabot metadata 21 | id: metadata 22 | uses: dependabot/fetch-metadata@v2.4.0 23 | with: 24 | github-token: "${{ secrets.GITHUB_TOKEN }}" 25 | 26 | - name: Enable auto-merge for Dependabot PRs 27 | # Only if version bump is not a major version change 28 | if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 29 | run: gh pr merge --auto --merge "$PR_URL" 30 | env: 31 | PR_URL: ${{github.event.pull_request.html_url}} 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug report, needs triage 6 | assignees: '' 7 | --- 8 | 9 | ### Describe the bug 10 | 11 | A clear and concise description of the bug. 12 | 13 | ### To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 1. Click on '....' 19 | 1. Scroll down to '....' 20 | 1. See error 21 | 22 | ### Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ### Screenshots 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | ### Desktop 31 | 32 | - OS: [e.g. iOS] 33 | - Browser [e.g. chrome, safari] 34 | - Version [e.g. 22] 35 | 36 | ### Dependency versions 37 | 38 | - Authentication library (MSAL, etc.) version: 39 | - Graph library (Graph SDK, REST library, etc.) version: 40 | 41 | ### Additional context 42 | 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | defaults: 10 | run: 11 | working-directory: src/GraphWebhooks 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | permissions: 18 | security-events: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup .NET 25 | uses: actions/setup-dotnet@v4 26 | with: 27 | dotnet-version: 8.x 28 | 29 | - name: Install dependencies 30 | run: dotnet restore 31 | 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v3 34 | with: 35 | languages: csharp 36 | 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@v3 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to 4 | agree to a Contributor License Agreement (CLA) declaring that you have the right to, 5 | and actually do, grant us the rights to use your contribution. For details, visit 6 | [https://cla.microsoft.com](https://cla.microsoft.com). 7 | 8 | When you submit a pull request, a CLA-bot will automatically determine whether you need 9 | to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the 10 | instructions provided by the bot. You will only need to do this once across all repositories using our CLA. 11 | 12 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 13 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 14 | or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 15 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Shared/Error.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @model ErrorViewModel 5 | @{ 6 | ViewData["Title"] = "Error"; 7 | } 8 | 9 |

Error.

10 |

An error occurred while processing your request.

11 | 12 | @if (Model.ShowRequestId) 13 | { 14 |

15 | Request ID: @Model.RequestId 16 |

17 | } 18 | 19 |

Development Mode

20 |

21 | Swapping to Development environment will display more detailed information about the error that occurred. 22 |

23 |

24 | The Development environment shouldn't be enabled for deployed applications. 25 | It can result in displaying sensitive information from exceptions to end users. 26 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 27 | and restarting the app. 28 |

29 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using System.Diagnostics; 5 | using GraphWebhooks.Models; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace GraphWebhooks.Controllers; 10 | 11 | /// 12 | /// The controller for the home page. 13 | /// 14 | public class HomeController() : Controller 15 | { 16 | /// 17 | /// GET /. 18 | /// 19 | /// An . 20 | [AllowAnonymous] 21 | public IActionResult Index() 22 | { 23 | return View(); 24 | } 25 | 26 | /// 27 | /// GET /error. 28 | /// 29 | /// An . 30 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 31 | public IActionResult Error() 32 | { 33 | return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/jquery-validation/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright Jörn Zaefferer 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/src/GraphWebhooks/GraphWebhooks.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/src/GraphWebhooks/GraphWebhooks.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/src/GraphWebhooks/GraphWebhooks.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2018 Twitter, Inc. 4 | Copyright (c) 2011-2018 The Bootstrap Authors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Home/Index.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @{ 5 | ViewData["Title"] = "Home Page"; 6 | } 7 | 8 |
9 |

Microsoft Graph Notifications Sample

10 |

Choose one of the options below to create a subscription

11 |
12 |
13 |
14 |
15 |

Delegated authentication

16 |

Choose this option to sign in as a user and receive notifications when items are created in the user's Exchange Online mailbox

17 | Sign in and subscribe 18 |
19 |
20 |
21 |
22 |

App-only authentication

23 |

Choose this option to have the application receive notifications when messages are sent to any Teams channel

24 | Subscribe 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /src/GraphWebhooks/GraphWebhooks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | eb160071-031a-4348-a86a-bc4f82c3775b 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | @ViewData["Title"] - GraphWebhooks 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | 20 | Microsoft Graph Notifications Sample 21 |
22 |
23 | 24 | @RenderBody() 25 |
26 |
27 | 28 | 29 | 30 | @await RenderSectionAsync("Scripts", required: false) 31 | 32 | 33 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/src/GraphWebhooks/bin/Debug/net8.0/GraphWebhooks.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/src/GraphWebhooks", 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen", 18 | "stopAtEntry": false, 19 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 20 | "serverReadyAction": { 21 | "action": "openExternally", 22 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 23 | }, 24 | "env": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "sourceFileMap": { 28 | "/Views": "${workspaceFolder}/Views" 29 | } 30 | }, 31 | { 32 | "name": ".NET Core Attach", 33 | "type": "coreclr", 34 | "request": "attach" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /aspnetcore-webhooks-sample.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.002.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EEB4E0F0-C427-4460-A375-3EAF31EEA7DD}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphWebhooks", "src\GraphWebhooks\GraphWebhooks.csproj", "{740F73DD-1BFC-40B3-8A15-535CF16D2B4A}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {740F73DD-1BFC-40B3-8A15-535CF16D2B4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {740F73DD-1BFC-40B3-8A15-535CF16D2B4A}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {740F73DD-1BFC-40B3-8A15-535CF16D2B4A}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {740F73DD-1BFC-40B3-8A15-535CF16D2B4A}.Release|Any CPU.Build.0 = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(SolutionProperties) = preSolution 22 | HideSolutionNode = FALSE 23 | EndGlobalSection 24 | GlobalSection(NestedProjects) = preSolution 25 | {740F73DD-1BFC-40B3-8A15-535CF16D2B4A} = {EEB4E0F0-C427-4460-A375-3EAF31EEA7DD} 26 | EndGlobalSection 27 | GlobalSection(ExtensibilityGlobals) = postSolution 28 | SolutionGuid = {DC3C6A55-C64E-44B2-9A94-780EAB22EF9D} 29 | EndGlobalSection 30 | EndGlobal 31 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/jquery/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright JS Foundation and other contributors, https://js.foundation/ 2 | 3 | This software consists of voluntary contributions made by many 4 | individuals. For exact contribution history, see the revision history 5 | available at https://github.com/jquery/jquery 6 | 7 | The following license applies to all parts of this software except as 8 | documented below: 9 | 10 | ==== 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining 13 | a copy of this software and associated documentation files (the 14 | "Software"), to deal in the Software without restriction, including 15 | without limitation the rights to use, copy, modify, merge, publish, 16 | distribute, sublicense, and/or sell copies of the Software, and to 17 | permit persons to whom the Software is furnished to do so, subject to 18 | the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be 21 | included in all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 24 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 26 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 27 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 28 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 29 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 | 31 | ==== 32 | 33 | All files located in the node_modules and external directories are 34 | externally maintained libraries used by this software which have their 35 | own licenses; we recommend you read them, as their terms may differ from 36 | the terms above. 37 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Services/SubscriptionStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using GraphWebhooks.Models; 5 | using Microsoft.Extensions.Caching.Memory; 6 | 7 | namespace GraphWebhooks.Services; 8 | 9 | /// 10 | /// Implements an in-memory store of subscriptions. 11 | /// 12 | public class SubscriptionStore(IMemoryCache memoryCache) 13 | { 14 | private readonly IMemoryCache cache = memoryCache ?? 15 | throw new ArgumentException(nameof(memoryCache)); 16 | 17 | /// 18 | /// Add a subscription record to the store. 19 | /// 20 | /// The subscription to add. 21 | public void SaveSubscriptionRecord(SubscriptionRecord record) 22 | { 23 | if (string.IsNullOrEmpty(record.Id)) 24 | { 25 | throw new Exception("ID of record cannot be empty"); 26 | } 27 | 28 | var options = new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromHours(2)); 29 | cache.Set(record.Id, record, options); 30 | } 31 | 32 | /// 33 | /// Get a subscription record. 34 | /// 35 | /// The subscription ID. 36 | /// The subscription record if found, null if not. 37 | public SubscriptionRecord? GetSubscriptionRecord(string subscriptionId) 38 | { 39 | cache.TryGetValue(subscriptionId, out var record); 40 | return record; 41 | } 42 | 43 | /// 44 | /// Delete a subscription record. 45 | /// 46 | /// The subscription ID. 47 | public void DeleteSubscriptionRecord(string subscriptionId) 48 | { 49 | cache.Remove(subscriptionId); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Alerts/WithAlertResult.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.Mvc.ViewFeatures; 6 | 7 | namespace GraphWebhooks; 8 | 9 | /// 10 | /// WithAlertResult adds temporary error/info/success 11 | /// messages to the result of a controller action. 12 | /// This data is read and displayed by the _AlertPartial view. 13 | /// 14 | public class WithAlertResult(IActionResult result, 15 | string type, 16 | string message, 17 | string? debugInfo) : IActionResult 18 | { 19 | /// 20 | /// Gets the result. 21 | /// 22 | public IActionResult Result { get; } = result; 23 | 24 | /// 25 | /// Gets the type of result. 26 | /// 27 | public string Type { get; } = type; 28 | 29 | /// 30 | /// Gets the result message. 31 | /// 32 | public string Message { get; } = message; 33 | 34 | /// 35 | /// Gets the debug information. 36 | /// 37 | public string? DebugInfo { get; } = debugInfo; 38 | 39 | /// 40 | public Task ExecuteResultAsync(ActionContext context) 41 | { 42 | _ = context ?? throw new ArgumentException("ActionContext cannot be null", nameof(context)); 43 | 44 | var factory = context.HttpContext.RequestServices 45 | .GetService() ?? 46 | throw new Exception("Could not get ITempDataDictionaryFactory"); 47 | 48 | var tempData = factory.GetTempData(context.HttpContext); 49 | 50 | tempData["_alertType"] = Type; 51 | tempData["_alertMessage"] = Message; 52 | tempData["_alertDebugInfo"] = DebugInfo; 53 | 54 | return Result.ExecuteResultAsync(context); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Watch/AppOnly.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @model Microsoft.Graph.Models.Subscription 5 | @using System.Text.Json 6 | @{ 7 | ViewData["Title"] = "Teams chat message notifications"; 8 | var options = new JsonSerializerOptions 9 | { 10 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 11 | WriteIndented = true 12 | }; 13 | var jsonSubscription = Model == null ? string.Empty : JsonSerializer.Serialize(Model, options); 14 | var subscriptionId = Model?.Id ?? string.Empty; 15 | } 16 | 17 |

Notifications

18 |

Notifications should appear below when new messages sent to any Teams channel in your organization.

19 |
20 | 21 | Delete subscription 22 |
23 |
24 |
25 | @jsonSubscription
26 |   
27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
SenderMessage
38 | 39 | @section Scripts { 40 | 41 | 67 | } 68 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Alerts/AlertExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace GraphWebhooks; 7 | 8 | /// 9 | /// Extension functions for IActionResult that add flash messages. 10 | /// 11 | public static class AlertExtensions 12 | { 13 | /// 14 | /// Adds error information to an . 15 | /// 16 | /// The to add information to. 17 | /// The error message. 18 | /// Optional debug information. 19 | /// The . 20 | public static IActionResult WithError( 21 | this IActionResult result, 22 | string message, 23 | string? debugInfo = null) 24 | { 25 | return Alert(result, "danger", message, debugInfo); 26 | } 27 | 28 | /// 29 | /// Adds success information to an . 30 | /// 31 | /// The to add information to. 32 | /// The success message. 33 | /// Optional debug information. 34 | /// The . 35 | public static IActionResult WithSuccess( 36 | this IActionResult result, 37 | string message, 38 | string? debugInfo = null) 39 | { 40 | return Alert(result, "success", message, debugInfo); 41 | } 42 | 43 | /// 44 | /// Adds information to an . 45 | /// 46 | /// The to add information to. 47 | /// The information message. 48 | /// Optional debug information. 49 | /// The . 50 | public static IActionResult WithInfo( 51 | this IActionResult result, 52 | string message, 53 | string? debugInfo = null) 54 | { 55 | return Alert(result, "info", message, debugInfo); 56 | } 57 | 58 | private static WithAlertResult Alert( 59 | IActionResult result, 60 | string type, 61 | string message, 62 | string? debugInfo) 63 | { 64 | return new WithAlertResult(result, type, message, debugInfo); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Views/Watch/Delegated.cshtml: -------------------------------------------------------------------------------- 1 | 3 | 4 | @model Microsoft.Graph.Models.Subscription 5 | @using System.Text.Json 6 | @{ 7 | ViewData["Title"] = "Mailbox notifications"; 8 | var options = new JsonSerializerOptions 9 | { 10 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 11 | WriteIndented = true 12 | }; 13 | var jsonSubscription = Model == null ? string.Empty : JsonSerializer.Serialize(Model, options); 14 | var subscriptionId = Model?.Id ?? string.Empty; 15 | } 16 | 17 |

Notifications

18 |

Notifications should appear below when new messages are delivered to @User.GetUserGraphDisplayName()'s inbox.

19 |
20 |
@User.GetUserGraphDisplayName()
21 | 22 | Delete subscription 23 |
24 |
25 |
26 | @jsonSubscription
27 |   
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
SubjectID
39 | 40 | @section Scripts { 41 | 42 | 68 | } 69 | -------------------------------------------------------------------------------- /KEYVAULT.md: -------------------------------------------------------------------------------- 1 | # Setting up Azure Key Vault 2 | 3 | ## Setting up a new Azure Key Vault 4 | 5 | For official documentation on creating an Azure Key Vault, see [Quickstart: Create a key vault using the Azure portal](https://learn.microsoft.com/azure/key-vault/general/quick-create-portal). 6 | 7 | > [!NOTE] 8 | > You only need to go through the following steps if you **do not** have an existing Azure Key Vault, or if you want to use a separate Azure Key Vault for this sample. 9 | 10 | 1. Go to [Azure Portal](https://portal.azure.com/). 11 | 12 | 1. In the portal menu, select **Create a resource**. 13 | 14 | ![The Create a resource menu item on the portal menu in the Azure Portal](images/create-a-resource.png) 15 | 16 | 1. Search for `Key Vault`. Select **Key Vault**, then select **Create**. 17 | 18 | 1. Fill in the required fields as appropriate for your Azure subscription, and select **Next: Access policy**. 19 | 20 | 1. Select **Add Access Policy**. 21 | 22 | 1. Change the **Configure from template** dropdown to **Key, Secret, & Certificate Management**. 23 | 24 | 1. For **Select principal**, select the **None selected**. Enter your application ID from your app registration in the search box, then select your app registration. Select **Select**. 25 | 26 | ![A screenshot of the Add access policy page in the Azure portal](images/add-access-policy.png) 27 | 28 | 1. Select **Add**, then select **Review + create**. Finally, select **Create**. 29 | 30 | 1. Wait for the Azure Key Vault to finish deploying, then follow the steps in [Adding a self-signed certificate](#adding-a-self-signed-certificate). 31 | 32 | ## Add your application to an existing Azure Key Vault 33 | 34 | > **Note:** you only need to go through the following steps if you have an existing Azure Key Vault you want to reuse for the current sample. 35 | 36 | 1. Go to your existing Azure Key Vault in the Azure portal. 37 | 38 | 1. Select **Access policies** under **Settings**. Select **Add Access Policy**. 39 | 40 | 1. Change the **Configure from template** dropdown to **Key, Secret, & Certificate Management**. 41 | 42 | 1. For **Select principal**, select the **None selected**. Enter your application ID from your app registration in the search box, then select your app registration. Select **Select**. 43 | 44 | 1. Select **Add** to add the new access policy. Wait for your access policy to deploy. 45 | 46 | ## Adding a self-signed certificate 47 | 48 | 1. Go to the Key Vault in the Azure Portal. 49 | 50 | 1. Select **Certificates** under **Settings**, then select **Generate/Import**. 51 | 52 | 1. Provide values for **Certificate Name** and **Subject**. Leave all other fields at their defaults, then select **Create**. 53 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Graph/GraphClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using System.Security.Claims; 5 | using Microsoft.Graph.Models; 6 | 7 | namespace GraphWebhooks; 8 | 9 | /// 10 | /// Extension methods for ClaimsPrincipal to add Graph information 11 | /// to the principal. 12 | /// 13 | public static class GraphClaimsPrincipalExtensions 14 | { 15 | /// 16 | /// Get the user's display name. 17 | /// 18 | /// The that contains the user's identity. 19 | /// The user's display name. 20 | public static string? GetUserGraphDisplayName(this ClaimsPrincipal claimsPrincipal) 21 | { 22 | return claimsPrincipal.FindFirstValue(GraphClaimTypes.DisplayName); 23 | } 24 | 25 | /// 26 | /// Get's the user's email address. 27 | /// 28 | /// The that contains the user's identity. 29 | /// The user's email address. 30 | public static string? GetUserGraphEmail(this ClaimsPrincipal claimsPrincipal) 31 | { 32 | return claimsPrincipal.FindFirstValue(GraphClaimTypes.Email); 33 | } 34 | 35 | /// 36 | /// Adds display name and email address to a ClaimsPrincipal. 37 | /// 38 | /// The that contains the user's identity. 39 | /// The object that contains the user's display name and email address. 40 | public static void AddUserGraphInfo(this ClaimsPrincipal claimsPrincipal, User? user) 41 | { 42 | _ = user ?? throw new ArgumentNullException(nameof(user)); 43 | if (claimsPrincipal.Identity is not ClaimsIdentity identity) 44 | { 45 | throw new Exception("Could not access identity"); 46 | } 47 | 48 | identity.AddClaim( 49 | new Claim(GraphClaimTypes.DisplayName, user.DisplayName ?? string.Empty)); 50 | identity.AddClaim( 51 | new Claim( 52 | GraphClaimTypes.Email, 53 | user.Mail ?? user.UserPrincipalName ?? string.Empty)); 54 | } 55 | 56 | /// 57 | /// Adds unique user ID and unique tenant ID to a ClaimsPrincipal. This is necessary for 58 | /// MSAL to extract the user's MSAL ID from the ClaimsPrincipal. 59 | /// 60 | /// The that contains the user's identity. 61 | /// The user's ID. 62 | /// The user's tenant ID. 63 | public static void AddMsalInfo(this ClaimsPrincipal claimsPrincipal, string uid, string utid) 64 | { 65 | if (claimsPrincipal.Identity is not ClaimsIdentity identity) 66 | { 67 | throw new Exception("Could not access identity"); 68 | } 69 | 70 | identity.AddClaim(new Claim("uid", uid)); 71 | identity.AddClaim(new Claim("utid", utid)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | This document covers some of the common issues you may encounter when running this sample. 4 | 5 | ## You get a 403 Forbidden response when you attempt to create a subscription 6 | 7 | Make sure that your app registration includes the required permission for Microsoft Graph (as described in the [Register the app](README.md#register-the-app) section). This permission must be set before you try to create a subscription. Otherwise you'll get an error. Then, make sure a tenant administrator has granted consent to the application. 8 | 9 | ## You do not receive notifications 10 | 11 | If you're using ngrok, you can use the web interface [http://127.0.0.1:4040](http://127.0.0.1:4040) to see whether the notification is being received. If you're not using ngrok, monitor the network traffic using the tools your hosting service provides, or try using ngrok. 12 | 13 | Known issue: Occasionally the notification is received, and the retrieved message is sent to NotificationService, but the SignalR client in this sample does not update. When this happens, it's usually the first notification after the subscription is created. 14 | 15 | ## You get a "Subscription validation request timed out" response 16 | 17 | This indicates that Microsoft Graph did not receive a validation response within the expected time frame (about 10 seconds). 18 | 19 | - Make sure that you are not paused in the debugger when the validation request is received. 20 | - If you're using ngrok, make sure that you used your project's HTTP port for the tunnel (not HTTPS). 21 | 22 | ## You get errors while installing packages 23 | 24 | Make sure the local path where you placed the solution is not too long/deep. Moving the solution closer to the root drive resolves this issue. 25 | 26 | ## The app opens to a *Server Error in '/' Application. 27 | 28 | **The resource cannot be found.* browser page.** 29 | 30 | Make sure that a CSHTML view file isn't the active tab when you run the app from Visual Studio. 31 | 32 | ## Hosting the sample without a tunnel 33 | 34 | Microsoft Graph (or any other webhook provider) needs a notification URL that it can reach to deliver notifications. The sample uses localhost as the development server. 35 | 36 | Localhost just means this host. If any webhook provider would deliver a notification to localhost, it would be delivering it to itself. Not very useful. 37 | 38 | Microsoft Graph can't deliver notifications to localhost. For this reason, we need a tunnel that can forward requests from a URL on the Internet to our localhost. 39 | 40 | There are some alternatives that you can consider to try this sample without a tunnel. 41 | 42 | ### Host the sample on a cloud service 43 | 44 | You can host the sample using a cloud service such as Microsoft Azure. Cloud services allow you to expose the notification URL to the Internet. Microsoft Graph can deliver notifications to the URL in the cloud. 45 | 46 | Note that in some cases, you'll be able to deploy the sample to a website hosted in the cloud. In other cases, you'll need to set up a virtual machine and install a development environment with the prerequisites listed in the [ReadMe](./README.md#prerequisites). 47 | 48 | See your cloud provider's documentation for details about how to host a web application or virtual machine using the cloud service. 49 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Services/CertificateService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using System.Security.Cryptography.X509Certificates; 5 | using Azure.Identity; 6 | using Azure.Security.KeyVault.Certificates; 7 | using Azure.Security.KeyVault.Secrets; 8 | 9 | namespace GraphWebhooks.Services; 10 | 11 | /// 12 | /// Implements methods to retrieve certificates from Azure Key Vault. 13 | /// 14 | public class CertificateService( 15 | IConfiguration configuration, 16 | ILogger logger) 17 | { 18 | private readonly IConfiguration config = configuration ?? 19 | throw new ArgumentException(nameof(configuration)); 20 | 21 | private readonly ILogger logger = logger ?? 22 | throw new ArgumentException(nameof(logger)); 23 | 24 | private byte[]? publicKeyBytes = null; 25 | 26 | private byte[]? privateKeyBytes = null; 27 | 28 | /// 29 | /// Gets the configured public key from the Azure Key Vault. 30 | /// 31 | /// The public key. 32 | public async Task GetEncryptionCertificate() 33 | { 34 | if (publicKeyBytes == null) 35 | { 36 | await LoadCertificates(); 37 | } 38 | 39 | return new X509Certificate2(publicKeyBytes ?? 40 | throw new Exception("Could not load encryption certificate")); 41 | } 42 | 43 | /// 44 | /// Gets the configure private key from the Azure Key Vault. 45 | /// 46 | /// The private key. 47 | public async Task GetDecryptionCertificate() 48 | { 49 | if (privateKeyBytes == null) 50 | { 51 | await LoadCertificates(); 52 | } 53 | 54 | return new X509Certificate2(privateKeyBytes ?? 55 | throw new Exception("Could not load decryption certificate")); 56 | } 57 | 58 | /// 59 | /// Extract the secret name from the secret ID. 60 | /// 61 | /// The URI to the secret. 62 | /// The secret name. 63 | /// Thrown if the secret ID is invalid. 64 | private static string ParseSecretName(Uri secretId) 65 | { 66 | // Secret IDs are URIs. The name is in the 67 | // third segment 68 | if (secretId.Segments.Length < 3) 69 | { 70 | throw new InvalidOperationException($@"The secret ""{secretId}"" does not contain a valid name."); 71 | } 72 | 73 | return secretId.Segments[2].TrimEnd('/'); 74 | } 75 | 76 | /// 77 | /// Gets the public and private keys from Azure Key Vault and caches the raw values. 78 | /// 79 | private async Task LoadCertificates() 80 | { 81 | // Load configuration values 82 | var tenantId = config.GetValue("AzureAd:TenantId"); 83 | var clientId = config.GetValue("AzureAd:ClientId"); 84 | var clientSecret = config.GetValue("AzureAd:ClientSecret"); 85 | var keyVaultUrl = new Uri(config.GetValue("KeyVault:Url") ?? 86 | throw new Exception("KeyVault url not set in appsettings")); 87 | var certificateName = config.GetValue("KeyVault:CertificateName"); 88 | 89 | logger.LogInformation("Loading certificate from Azure Key Vault"); 90 | 91 | // Authenticate as the app to connect to Azure Key Vault 92 | var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); 93 | 94 | // CertificateClient can get the public key 95 | var certClient = new CertificateClient(keyVaultUrl, credential); 96 | 97 | // Secret client can get the private key 98 | var secretClient = new SecretClient(keyVaultUrl, credential); 99 | 100 | // Get the public key 101 | var publicCertificate = await certClient.GetCertificateAsync(certificateName); 102 | 103 | // Each certificate that has a private key in Azure Key Vault has a corresponding 104 | // secret ID. Use this to get the private key 105 | var privateCertificate = await secretClient.GetSecretAsync(ParseSecretName(publicCertificate.Value.SecretId)); 106 | 107 | publicKeyBytes = publicCertificate.Value.Cer; 108 | privateKeyBytes = Convert.FromBase64String(privateCertificate.Value.Value); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /.github/policies/resourceManagement.yml: -------------------------------------------------------------------------------- 1 | id: 2 | name: GitOps.PullRequestIssueManagement 3 | description: GitOps.PullRequestIssueManagement primitive 4 | owner: 5 | resource: repository 6 | disabled: false 7 | where: 8 | configuration: 9 | resourceManagementConfiguration: 10 | scheduledSearches: 11 | - description: 12 | frequencies: 13 | - hourly: 14 | hour: 6 15 | filters: 16 | - isIssue 17 | - isOpen 18 | - hasLabel: 19 | label: needs author feedback 20 | - hasLabel: 21 | label: no recent activity 22 | - noActivitySince: 23 | days: 3 24 | actions: 25 | - closeIssue 26 | - description: 27 | frequencies: 28 | - hourly: 29 | hour: 6 30 | filters: 31 | - isIssue 32 | - isOpen 33 | - hasLabel: 34 | label: needs author feedback 35 | - noActivitySince: 36 | days: 4 37 | - isNotLabeledWith: 38 | label: no recent activity 39 | actions: 40 | - addLabel: 41 | label: no recent activity 42 | - addReply: 43 | reply: This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for **4 days**. It will be closed if no further activity occurs **within 3 days of this comment**. 44 | - description: 45 | frequencies: 46 | - hourly: 47 | hour: 6 48 | filters: 49 | - isIssue 50 | - isOpen 51 | - hasLabel: 52 | label: duplicate 53 | - noActivitySince: 54 | days: 1 55 | actions: 56 | - addReply: 57 | reply: This issue has been marked as duplicate and has not had any activity for **1 day**. It will be closed for housekeeping purposes. 58 | - closeIssue 59 | - description: 60 | frequencies: 61 | - hourly: 62 | hour: 3 63 | filters: 64 | - isOpen 65 | - isIssue 66 | - hasLabel: 67 | label: graph question 68 | actions: 69 | - removeLabel: 70 | label: 'needs attention :wave:' 71 | - removeLabel: 72 | label: needs author feedback 73 | - removeLabel: 74 | label: 'needs triage :mag:' 75 | - removeLabel: 76 | label: no recent activity 77 | - addLabel: 78 | label: out of scope 79 | - addReply: 80 | reply: >- 81 | It looks like you are asking a question about using Microsoft Graph or one of the Microsoft Graph SDKs that is not directly related to this sample. Unfortunately we are not set up to answer general questions in this repository, so this issue will be closed. 82 | 83 | 84 | Please try asking your question on [Microsoft Q&A](https://docs.microsoft.com/answers/products/graph). 85 | - closeIssue 86 | - description: 87 | frequencies: 88 | - hourly: 89 | hour: 3 90 | filters: 91 | - isOpen 92 | - isIssue 93 | - hasLabel: 94 | label: graph issue 95 | actions: 96 | - removeLabel: 97 | label: 'needs attention :wave:' 98 | - removeLabel: 99 | label: needs author feedback 100 | - removeLabel: 101 | label: 'needs triage :mag:' 102 | - removeLabel: 103 | label: no recent activity 104 | - addLabel: 105 | label: out of scope 106 | - addReply: 107 | reply: >- 108 | It looks like you are reporting an issue with Microsoft Graph or one of the Microsoft Graph SDKs that is not fixable by changing code in this sample. Unfortunately we are not set up to provide product support in this repository, so this issue will be closed. 109 | 110 | 111 | Please visit one of the following links to report your issue. 112 | 113 | 114 | - Issue with Microsoft Graph service: [Microsoft Graph support](https://developer.microsoft.com/graph/support#report-issues-with-the-service), choose one of the options under **Report issues with the service** 115 | 116 | - Issue with a Microsoft Graph SDK: Open an issue in the SDK's GitHub repository. See [microsoftgraph on GitHub](https://github.com/microsoftgraph?q=sdk+in%3Aname&type=public&language=) for a list of SDK repositories. 117 | - closeIssue 118 | eventResponderTasks: 119 | - if: 120 | - payloadType: Issue_Comment 121 | - isAction: 122 | action: Created 123 | - isActivitySender: 124 | issueAuthor: True 125 | - hasLabel: 126 | label: needs author feedback 127 | - isOpen 128 | then: 129 | - addLabel: 130 | label: 'needs attention :wave:' 131 | - removeLabel: 132 | label: needs author feedback 133 | description: 134 | - if: 135 | - payloadType: Issues 136 | - not: 137 | isAction: 138 | action: Closed 139 | - hasLabel: 140 | label: no recent activity 141 | then: 142 | - removeLabel: 143 | label: no recent activity 144 | description: 145 | - if: 146 | - payloadType: Issue_Comment 147 | - hasLabel: 148 | label: no recent activity 149 | then: 150 | - removeLabel: 151 | label: no recent activity 152 | description: 153 | - if: 154 | - payloadType: Pull_Request 155 | then: 156 | - inPrLabel: 157 | label: in pr 158 | description: 159 | onFailure: 160 | onSuccess: 161 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js: -------------------------------------------------------------------------------- 1 | // Unobtrusive validation support library for jQuery and jQuery Validate 2 | // Copyright (c) .NET Foundation. All rights reserved. 3 | // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. 4 | // @version v3.2.11 5 | !function(a){"function"==typeof define&&define.amd?define("jquery.validate.unobtrusive",["jquery-validation"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery-validation")):jQuery.validator.unobtrusive=a(jQuery)}(function(a){function e(a,e,n){a.rules[e]=n,a.message&&(a.messages[e]=a.message)}function n(a){return a.replace(/^\s+|\s+$/g,"").split(/\s*,\s*/g)}function t(a){return a.replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g,"\\$1")}function r(a){return a.substr(0,a.lastIndexOf(".")+1)}function i(a,e){return 0===a.indexOf("*.")&&(a=a.replace("*.",e)),a}function o(e,n){var r=a(this).find("[data-valmsg-for='"+t(n[0].name)+"']"),i=r.attr("data-valmsg-replace"),o=i?a.parseJSON(i)!==!1:null;r.removeClass("field-validation-valid").addClass("field-validation-error"),e.data("unobtrusiveContainer",r),o?(r.empty(),e.removeClass("input-validation-error").appendTo(r)):e.hide()}function d(e,n){var t=a(this).find("[data-valmsg-summary=true]"),r=t.find("ul");r&&r.length&&n.errorList.length&&(r.empty(),t.addClass("validation-summary-errors").removeClass("validation-summary-valid"),a.each(n.errorList,function(){a("
  • ").html(this.message).appendTo(r)}))}function s(e){var n=e.data("unobtrusiveContainer");if(n){var t=n.attr("data-valmsg-replace"),r=t?a.parseJSON(t):null;n.addClass("field-validation-valid").removeClass("field-validation-error"),e.removeData("unobtrusiveContainer"),r&&n.empty()}}function l(e){var n=a(this),t="__jquery_unobtrusive_validation_form_reset";if(!n.data(t)){n.data(t,!0);try{n.data("validator").resetForm()}finally{n.removeData(t)}n.find(".validation-summary-errors").addClass("validation-summary-valid").removeClass("validation-summary-errors"),n.find(".field-validation-error").addClass("field-validation-valid").removeClass("field-validation-error").removeData("unobtrusiveContainer").find(">*").removeData("unobtrusiveContainer")}}function u(e){var n=a(e),t=n.data(v),r=a.proxy(l,e),i=f.unobtrusive.options||{},u=function(n,t){var r=i[n];r&&a.isFunction(r)&&r.apply(e,t)};return t||(t={options:{errorClass:i.errorClass||"input-validation-error",errorElement:i.errorElement||"span",errorPlacement:function(){o.apply(e,arguments),u("errorPlacement",arguments)},invalidHandler:function(){d.apply(e,arguments),u("invalidHandler",arguments)},messages:{},rules:{},success:function(){s.apply(e,arguments),u("success",arguments)}},attachValidation:function(){n.off("reset."+v,r).on("reset."+v,r).validate(this.options)},validate:function(){return n.validate(),n.valid()}},n.data(v,t)),t}var m,f=a.validator,v="unobtrusiveValidation";return f.unobtrusive={adapters:[],parseElement:function(e,n){var t,r,i,o=a(e),d=o.parents("form")[0];d&&(t=u(d),t.options.rules[e.name]=r={},t.options.messages[e.name]=i={},a.each(this.adapters,function(){var n="data-val-"+this.name,t=o.attr(n),s={};void 0!==t&&(n+="-",a.each(this.params,function(){s[this]=o.attr(n+this)}),this.adapt({element:e,form:d,message:t,params:s,rules:r,messages:i}))}),a.extend(r,{__dummy__:!0}),n||t.attachValidation())},parse:function(e){var n=a(e),t=n.parents().addBack().filter("form").add(n.find("form")).has("[data-val=true]");n.find("[data-val=true]").each(function(){f.unobtrusive.parseElement(this,!0)}),t.each(function(){var a=u(this);a&&a.attachValidation()})}},m=f.unobtrusive.adapters,m.add=function(a,e,n){return n||(n=e,e=[]),this.push({name:a,params:e,adapt:n}),this},m.addBool=function(a,n){return this.add(a,function(t){e(t,n||a,!0)})},m.addMinMax=function(a,n,t,r,i,o){return this.add(a,[i||"min",o||"max"],function(a){var i=a.params.min,o=a.params.max;i&&o?e(a,r,[i,o]):i?e(a,n,i):o&&e(a,t,o)})},m.addSingleVal=function(a,n,t){return this.add(a,[n||"val"],function(r){e(r,t||a,r.params[n])})},f.addMethod("__dummy__",function(a,e,n){return!0}),f.addMethod("regex",function(a,e,n){var t;return!!this.optional(e)||(t=new RegExp(n).exec(a),t&&0===t.index&&t[0].length===a.length)}),f.addMethod("nonalphamin",function(a,e,n){var t;return n&&(t=a.match(/\W/g),t=t&&t.length>=n),t}),f.methods.extension?(m.addSingleVal("accept","mimtype"),m.addSingleVal("extension","extension")):m.addSingleVal("extension","extension","accept"),m.addSingleVal("regex","pattern"),m.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"),m.addMinMax("length","minlength","maxlength","rangelength").addMinMax("range","min","max","range"),m.addMinMax("minlength","minlength").addMinMax("maxlength","minlength","maxlength"),m.add("equalto",["other"],function(n){var o=r(n.element.name),d=n.params.other,s=i(d,o),l=a(n.form).find(":input").filter("[name='"+t(s)+"']")[0];e(n,"equalTo",l)}),m.add("required",function(a){"INPUT"===a.element.tagName.toUpperCase()&&"CHECKBOX"===a.element.type.toUpperCase()||e(a,"required",!0)}),m.add("remote",["url","type","additionalfields"],function(o){var d={url:o.params.url,type:o.params.type||"GET",data:{}},s=r(o.element.name);a.each(n(o.params.additionalfields||o.element.name),function(e,n){var r=i(n,s);d.data[r]=function(){var e=a(o.form).find(":input").filter("[name='"+t(r)+"']");return e.is(":checkbox")?e.filter(":checked").val()||e.filter(":hidden").val()||"":e.is(":radio")?e.filter(":checked").val()||"":e.val()}}),e(o,"remote",d)}),m.add("password",["min","nonalphamin","regex"],function(a){a.params.min&&e(a,"minlength",a.params.min),a.params.nonalphamin&&e(a,"nonalphamin",a.params.nonalphamin),a.params.regex&&e(a,"regex",a.params.regex)}),m.add("fileextensions",["extensions"],function(a){e(a,"extension",a.params.extensions)}),a(function(){f.unobtrusive.parse(document)}),f.unobtrusive}); -------------------------------------------------------------------------------- /src/GraphWebhooks/Controllers/LifecycleController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using GraphWebhooks.Services; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Graph; 8 | using Microsoft.Graph.Models; 9 | using Microsoft.Identity.Web; 10 | using Microsoft.Kiota.Abstractions; 11 | using Microsoft.Kiota.Abstractions.Serialization; 12 | using Microsoft.Kiota.Serialization.Json; 13 | 14 | namespace GraphWebhooks.Controllers; 15 | 16 | /// 17 | /// Implements the lifecycle notification endpoint which receives 18 | /// notifications from Microsoft Graph. 19 | /// 20 | public class LifecycleController( 21 | GraphServiceClient graphClient, 22 | SubscriptionStore subscriptionStore, 23 | ILogger logger) : Controller 24 | { 25 | private readonly GraphServiceClient graphClient = graphClient ?? 26 | throw new ArgumentException(nameof(graphClient)); 27 | 28 | private readonly SubscriptionStore subscriptionStore = subscriptionStore ?? 29 | throw new ArgumentException(nameof(subscriptionStore)); 30 | 31 | private readonly ILogger logger = logger ?? 32 | throw new ArgumentException(nameof(logger)); 33 | 34 | /// 35 | /// POST /lifecycle. 36 | /// 37 | /// Optional. Validation token sent by Microsoft Graph during endpoint validation phase. 38 | /// An . 39 | [HttpPost] 40 | [AllowAnonymous] 41 | public async Task Index([FromQuery] string? validationToken = null) 42 | { 43 | // If there is a validation token in the query string, 44 | // send it back in a 200 OK text/plain response 45 | if (!string.IsNullOrEmpty(validationToken)) 46 | { 47 | return Ok(validationToken); 48 | } 49 | 50 | // Use the Graph client's serializer to deserialize the body 51 | using var bodyStream = new MemoryStream(); 52 | await Request.Body.CopyToAsync(bodyStream); 53 | bodyStream.Seek(0, SeekOrigin.Begin); 54 | 55 | // Calling RegisterDefaultDeserializer here isn't strictly necessary since 56 | // we have a GraphServiceClient instance. In cases where you do not have a 57 | // GraphServiceClient, you need to register the JSON provider before trying 58 | // to deserialize. 59 | ApiClientBuilder.RegisterDefaultDeserializer(); 60 | var notifications = KiotaJsonSerializer.Deserialize(bodyStream); 61 | 62 | if (notifications == null || notifications.Value == null) 63 | { 64 | return Accepted(); 65 | } 66 | 67 | // Process any lifecycle events 68 | var lifecycleNotifications = notifications.Value.Where(n => n.LifecycleEvent != null); 69 | foreach (var lifecycleNotification in lifecycleNotifications) 70 | { 71 | logger.LogInformation( 72 | "Received {eventType} notification for subscription {subscriptionId}", 73 | lifecycleNotification.LifecycleEvent.ToString(), 74 | lifecycleNotification.SubscriptionId); 75 | 76 | if (lifecycleNotification.LifecycleEvent == LifecycleEventType.ReauthorizationRequired) 77 | { 78 | // The subscription needs to be renewed 79 | try 80 | { 81 | await RenewSubscriptionAsync(lifecycleNotification); 82 | } 83 | catch (Exception ex) 84 | { 85 | logger.LogError(ex, "Error renewing subscription"); 86 | } 87 | } 88 | } 89 | 90 | // Return 202 to Graph to confirm receipt of notification. 91 | // Not sending this will cause Graph to retry the notification. 92 | return Accepted(); 93 | } 94 | 95 | private async Task RenewSubscriptionAsync(ChangeNotification lifecycleNotification) 96 | { 97 | var subscriptionId = lifecycleNotification.SubscriptionId?.ToString(); 98 | 99 | if (!string.IsNullOrEmpty(subscriptionId)) 100 | { 101 | var subscription = subscriptionStore.GetSubscriptionRecord(subscriptionId); 102 | if (subscription != null && 103 | !string.IsNullOrEmpty(subscription.UserId) && 104 | !string.IsNullOrEmpty(subscription.TenantId)) 105 | { 106 | var isAppOnly = subscription.UserId.Equals("APP-ONLY", StringComparison.OrdinalIgnoreCase); 107 | if (!isAppOnly) 108 | { 109 | // Since the POST comes from Graph, there's no user in the context 110 | // Set the user to the user that owns the message. This will enable 111 | // Microsoft.Identity.Web to acquire the proper token for the proper user 112 | HttpContext.User = ClaimsPrincipalFactory 113 | .FromTenantIdAndObjectId(subscription.TenantId, subscription.UserId); 114 | HttpContext.User.AddMsalInfo(subscription.UserId, subscription.TenantId); 115 | } 116 | 117 | var update = new Subscription 118 | { 119 | ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1), 120 | }; 121 | 122 | await graphClient.Subscriptions[subscriptionId] 123 | .PatchAsync(update, req => 124 | { 125 | req.Options.WithAppOnly(isAppOnly); 126 | }); 127 | 128 | logger.LogInformation("Renewed subscription"); 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */ -------------------------------------------------------------------------------- /src/GraphWebhooks/Startup.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using System.Net; 5 | using GraphWebhooks.Services; 6 | using GraphWebhooks.SignalR; 7 | using Microsoft.AspNetCore.Authentication.OpenIdConnect; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.AspNetCore.Mvc.Authorization; 10 | using Microsoft.Identity.Web; 11 | using Microsoft.Identity.Web.UI; 12 | using Microsoft.IdentityModel.Protocols.OpenIdConnect; 13 | 14 | namespace GraphWebhooks; 15 | 16 | /// 17 | /// Startup class to configure ASP.NET middlewares and services. 18 | /// 19 | /// The app configuration. 20 | public class Startup(IConfiguration configuration) 21 | { 22 | private IConfiguration Configuration { get; } = configuration ?? 23 | throw new ArgumentException(nameof(configuration)); 24 | 25 | /// 26 | /// This method gets called by the runtime. Use this method to add services to the container. 27 | /// 28 | /// The service collection provided by the runtime. 29 | /// Thrown if the service collection is null. 30 | public void ConfigureServices(IServiceCollection services) 31 | { 32 | _ = services ?? throw new ArgumentException("Service collection cannot be null", nameof(services)); 33 | 34 | var scopesString = Configuration?.GetValue("GraphScopes") ?? "User.Read"; 35 | var scopesArray = scopesString.Split(' '); 36 | services 37 | 38 | // Use OpenId authentication 39 | .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) 40 | 41 | // Specify this is a web app and needs auth code flow 42 | .AddMicrosoftIdentityWebApp(options => 43 | { 44 | Configuration?.Bind("AzureAd", options); 45 | 46 | options.Prompt = "select_account"; 47 | 48 | options.Events.OnAuthenticationFailed = context => 49 | { 50 | var error = WebUtility.UrlEncode(context.Exception.Message); 51 | context.Response 52 | .Redirect($"/Home/ErrorWithMessage?message=Authentication+error&debug={error}"); 53 | context.HandleResponse(); 54 | 55 | return Task.FromResult(0); 56 | }; 57 | 58 | options.Events.OnRemoteFailure = context => 59 | { 60 | if (context.Failure is OpenIdConnectProtocolException) 61 | { 62 | var error = WebUtility.UrlEncode(context.Failure.Message); 63 | context.Response 64 | .Redirect($"/Home/ErrorWithMessage?message=Sign+in+error&debug={error}"); 65 | context.HandleResponse(); 66 | } 67 | 68 | return Task.FromResult(0); 69 | }; 70 | }) 71 | 72 | // Add ability to call web API (Graph) 73 | // and get access tokens 74 | .EnableTokenAcquisitionToCallDownstreamApi( 75 | options => 76 | { 77 | Configuration?.Bind("AzureAd", options); 78 | }, 79 | scopesArray) 80 | 81 | // Add a GraphServiceClient via dependency injection 82 | .AddMicrosoftGraph(options => 83 | { 84 | options.Scopes = scopesArray; 85 | }) 86 | 87 | // Use in-memory token cache 88 | // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization 89 | .AddInMemoryTokenCaches(); 90 | 91 | // Add custom services 92 | services.AddSingleton(); 93 | services.AddSingleton(); 94 | 95 | // Add SignalR 96 | services 97 | .AddSignalR(options => options.EnableDetailedErrors = true) 98 | .AddJsonProtocol(); 99 | 100 | services.AddMvc(options => 101 | { 102 | var policy = new AuthorizationPolicyBuilder() 103 | .RequireAuthenticatedUser() 104 | .Build(); 105 | options.Filters.Add(new AuthorizeFilter(policy)); 106 | }) 107 | 108 | // Add the Microsoft Identity UI pages for signin/out 109 | .AddMicrosoftIdentityUI(); 110 | } 111 | 112 | /// 113 | /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 114 | /// 115 | /// The application builder provided by the runtime. 116 | /// The web host environment provided by the runtime. 117 | /// Thrown if any parameter is null. 118 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 119 | { 120 | _ = app ?? throw new ArgumentException("IApplicationBuilder cannot be null", nameof(app)); 121 | _ = env ?? throw new ArgumentException("IWebHostEnvironment cannot be null", nameof(env)); 122 | 123 | if (env.IsDevelopment()) 124 | { 125 | app.UseDeveloperExceptionPage(); 126 | } 127 | else 128 | { 129 | app.UseExceptionHandler("/Home/Error"); 130 | 131 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 132 | app.UseHsts(); 133 | } 134 | 135 | app.UseHttpsRedirection(); 136 | app.UseStaticFiles(); 137 | 138 | app.UseRouting(); 139 | 140 | app.UseAuthentication(); 141 | app.UseAuthorization(); 142 | 143 | app.UseEndpoints(endpoints => 144 | { 145 | // Need Razor pages for Microsoft.Identity.Web.UI's pages to work 146 | endpoints.MapRazorPages(); 147 | endpoints.MapControllerRoute( 148 | name: "default", 149 | pattern: "{controller=Home}/{action=Index}/{id?}"); 150 | 151 | // Add SignalR notification hub 152 | endpoints.MapHub("/NotificationHub"); 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.vspscc 94 | *.vssscc 95 | .builds 96 | *.pidb 97 | *.svclog 98 | *.scc 99 | 100 | # Chutzpah Test files 101 | _Chutzpah* 102 | 103 | # Visual C++ cache files 104 | ipch/ 105 | *.aps 106 | *.ncb 107 | *.opendb 108 | *.opensdf 109 | *.sdf 110 | *.cachefile 111 | *.VC.db 112 | *.VC.VC.opendb 113 | 114 | # Visual Studio profiler 115 | *.psess 116 | *.vsp 117 | *.vspx 118 | *.sap 119 | 120 | # Visual Studio Trace Files 121 | *.e2e 122 | 123 | # TFS 2012 Local Workspace 124 | $tf/ 125 | 126 | # Guidance Automation Toolkit 127 | *.gpState 128 | 129 | # ReSharper is a .NET coding add-in 130 | _ReSharper*/ 131 | *.[Rr]e[Ss]harper 132 | *.DotSettings.user 133 | 134 | # TeamCity is a build add-in 135 | _TeamCity* 136 | 137 | # DotCover is a Code Coverage Tool 138 | *.dotCover 139 | 140 | # AxoCover is a Code Coverage Tool 141 | .axoCover/* 142 | !.axoCover/settings.json 143 | 144 | # Coverlet is a free, cross platform Code Coverage Tool 145 | coverage*[.json, .xml, .info] 146 | 147 | # Visual Studio code coverage results 148 | *.coverage 149 | *.coveragexml 150 | 151 | # NCrunch 152 | _NCrunch_* 153 | .*crunch*.local.xml 154 | nCrunchTemp_* 155 | 156 | # MightyMoose 157 | *.mm.* 158 | AutoTest.Net/ 159 | 160 | # Web workbench (sass) 161 | .sass-cache/ 162 | 163 | # Installshield output folder 164 | [Ee]xpress/ 165 | 166 | # DocProject is a documentation generator add-in 167 | DocProject/buildhelp/ 168 | DocProject/Help/*.HxT 169 | DocProject/Help/*.HxC 170 | DocProject/Help/*.hhc 171 | DocProject/Help/*.hhk 172 | DocProject/Help/*.hhp 173 | DocProject/Help/Html2 174 | DocProject/Help/html 175 | 176 | # Click-Once directory 177 | publish/ 178 | 179 | # Publish Web Output 180 | *.[Pp]ublish.xml 181 | *.azurePubxml 182 | # Note: Comment the next line if you want to checkin your web deploy settings, 183 | # but database connection strings (with potential passwords) will be unencrypted 184 | *.pubxml 185 | *.publishproj 186 | 187 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 188 | # checkin your Azure Web App publish settings, but sensitive information contained 189 | # in these scripts will be unencrypted 190 | PublishScripts/ 191 | 192 | # NuGet Packages 193 | *.nupkg 194 | # NuGet Symbol Packages 195 | *.snupkg 196 | # The packages folder can be ignored because of Package Restore 197 | **/[Pp]ackages/* 198 | # except build/, which is used as an MSBuild target. 199 | !**/[Pp]ackages/build/ 200 | # Uncomment if necessary however generally it will be regenerated when needed 201 | #!**/[Pp]ackages/repositories.config 202 | # NuGet v3's project.json files produces more ignorable files 203 | *.nuget.props 204 | *.nuget.targets 205 | 206 | # Microsoft Azure Build Output 207 | csx/ 208 | *.build.csdef 209 | 210 | # Microsoft Azure Emulator 211 | ecf/ 212 | rcf/ 213 | 214 | # Windows Store app package directories and files 215 | AppPackages/ 216 | BundleArtifacts/ 217 | Package.StoreAssociation.xml 218 | _pkginfo.txt 219 | *.appx 220 | *.appxbundle 221 | *.appxupload 222 | 223 | # Visual Studio cache files 224 | # files ending in .cache can be ignored 225 | *.[Cc]ache 226 | # but keep track of directories ending in .cache 227 | !?*.[Cc]ache/ 228 | 229 | # Others 230 | ClientBin/ 231 | ~$* 232 | *~ 233 | *.dbmdl 234 | *.dbproj.schemaview 235 | *.jfm 236 | *.pfx 237 | *.publishsettings 238 | orleans.codegen.cs 239 | 240 | # Including strong name files can present a security risk 241 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 242 | #*.snk 243 | 244 | # Since there are multiple workflows, uncomment next line to ignore bower_components 245 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 246 | #bower_components/ 247 | 248 | # RIA/Silverlight projects 249 | Generated_Code/ 250 | 251 | # Backup & report files from converting an old project file 252 | # to a newer Visual Studio version. Backup files are not needed, 253 | # because we have git ;-) 254 | _UpgradeReport_Files/ 255 | Backup*/ 256 | UpgradeLog*.XML 257 | UpgradeLog*.htm 258 | ServiceFabricBackup/ 259 | *.rptproj.bak 260 | 261 | # SQL Server files 262 | *.mdf 263 | *.ldf 264 | *.ndf 265 | 266 | # Business Intelligence projects 267 | *.rdl.data 268 | *.bim.layout 269 | *.bim_*.settings 270 | *.rptproj.rsuser 271 | *- [Bb]ackup.rdl 272 | *- [Bb]ackup ([0-9]).rdl 273 | *- [Bb]ackup ([0-9][0-9]).rdl 274 | 275 | # Microsoft Fakes 276 | FakesAssemblies/ 277 | 278 | # GhostDoc plugin setting file 279 | *.GhostDoc.xml 280 | 281 | # Node.js Tools for Visual Studio 282 | .ntvs_analysis.dat 283 | node_modules/ 284 | 285 | # Visual Studio 6 build log 286 | *.plg 287 | 288 | # Visual Studio 6 workspace options file 289 | *.opt 290 | 291 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 292 | *.vbw 293 | 294 | # Visual Studio LightSwitch build output 295 | **/*.HTMLClient/GeneratedArtifacts 296 | **/*.DesktopClient/GeneratedArtifacts 297 | **/*.DesktopClient/ModelManifest.xml 298 | **/*.Server/GeneratedArtifacts 299 | **/*.Server/ModelManifest.xml 300 | _Pvt_Extensions 301 | 302 | # Paket dependency manager 303 | .paket/paket.exe 304 | paket-files/ 305 | 306 | # FAKE - F# Make 307 | .fake/ 308 | 309 | # CodeRush personal settings 310 | .cr/personal 311 | 312 | # Python Tools for Visual Studio (PTVS) 313 | __pycache__/ 314 | *.pyc 315 | 316 | # Cake - Uncomment if you are using it 317 | # tools/** 318 | # !tools/packages.config 319 | 320 | # Tabs Studio 321 | *.tss 322 | 323 | # Telerik's JustMock configuration file 324 | *.jmconfig 325 | 326 | # BizTalk build output 327 | *.btp.cs 328 | *.btm.cs 329 | *.odx.cs 330 | *.xsd.cs 331 | 332 | # OpenCover UI analysis results 333 | OpenCover/ 334 | 335 | # Azure Stream Analytics local run output 336 | ASALocalRun/ 337 | 338 | # MSBuild Binary and Structured Log 339 | *.binlog 340 | 341 | # NVidia Nsight GPU debugger configuration file 342 | *.nvuser 343 | 344 | # MFractors (Xamarin productivity tool) working folder 345 | .mfractor/ 346 | 347 | # Local History for Visual Studio 348 | .localhistory/ 349 | 350 | # BeatPulse healthcheck temp database 351 | healthchecksdb 352 | 353 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 354 | MigrationBackup/ 355 | 356 | # Ionide (cross platform F# VS Code tools) working folder 357 | .ionide/ 358 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | :root { 9 | --bs-blue: #0d6efd; 10 | --bs-indigo: #6610f2; 11 | --bs-purple: #6f42c1; 12 | --bs-pink: #d63384; 13 | --bs-red: #dc3545; 14 | --bs-orange: #fd7e14; 15 | --bs-yellow: #ffc107; 16 | --bs-green: #198754; 17 | --bs-teal: #20c997; 18 | --bs-cyan: #0dcaf0; 19 | --bs-white: #fff; 20 | --bs-gray: #6c757d; 21 | --bs-gray-dark: #343a40; 22 | --bs-gray-100: #f8f9fa; 23 | --bs-gray-200: #e9ecef; 24 | --bs-gray-300: #dee2e6; 25 | --bs-gray-400: #ced4da; 26 | --bs-gray-500: #adb5bd; 27 | --bs-gray-600: #6c757d; 28 | --bs-gray-700: #495057; 29 | --bs-gray-800: #343a40; 30 | --bs-gray-900: #212529; 31 | --bs-primary: #0d6efd; 32 | --bs-secondary: #6c757d; 33 | --bs-success: #198754; 34 | --bs-info: #0dcaf0; 35 | --bs-warning: #ffc107; 36 | --bs-danger: #dc3545; 37 | --bs-light: #f8f9fa; 38 | --bs-dark: #212529; 39 | --bs-primary-rgb: 13, 110, 253; 40 | --bs-secondary-rgb: 108, 117, 125; 41 | --bs-success-rgb: 25, 135, 84; 42 | --bs-info-rgb: 13, 202, 240; 43 | --bs-warning-rgb: 255, 193, 7; 44 | --bs-danger-rgb: 220, 53, 69; 45 | --bs-light-rgb: 248, 249, 250; 46 | --bs-dark-rgb: 33, 37, 41; 47 | --bs-white-rgb: 255, 255, 255; 48 | --bs-black-rgb: 0, 0, 0; 49 | --bs-body-color-rgb: 33, 37, 41; 50 | --bs-body-bg-rgb: 255, 255, 255; 51 | --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 52 | --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 53 | --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); 54 | --bs-body-font-family: var(--bs-font-sans-serif); 55 | --bs-body-font-size: 1rem; 56 | --bs-body-font-weight: 400; 57 | --bs-body-line-height: 1.5; 58 | --bs-body-color: #212529; 59 | --bs-body-bg: #fff; 60 | } 61 | 62 | *, 63 | *::before, 64 | *::after { 65 | box-sizing: border-box; 66 | } 67 | 68 | @media (prefers-reduced-motion: no-preference) { 69 | :root { 70 | scroll-behavior: smooth; 71 | } 72 | } 73 | 74 | body { 75 | margin: 0; 76 | font-family: var(--bs-body-font-family); 77 | font-size: var(--bs-body-font-size); 78 | font-weight: var(--bs-body-font-weight); 79 | line-height: var(--bs-body-line-height); 80 | color: var(--bs-body-color); 81 | text-align: var(--bs-body-text-align); 82 | background-color: var(--bs-body-bg); 83 | -webkit-text-size-adjust: 100%; 84 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 85 | } 86 | 87 | hr { 88 | margin: 1rem 0; 89 | color: inherit; 90 | background-color: currentColor; 91 | border: 0; 92 | opacity: 0.25; 93 | } 94 | 95 | hr:not([size]) { 96 | height: 1px; 97 | } 98 | 99 | h6, h5, h4, h3, h2, h1 { 100 | margin-top: 0; 101 | margin-bottom: 0.5rem; 102 | font-weight: 500; 103 | line-height: 1.2; 104 | } 105 | 106 | h1 { 107 | font-size: calc(1.375rem + 1.5vw); 108 | } 109 | @media (min-width: 1200px) { 110 | h1 { 111 | font-size: 2.5rem; 112 | } 113 | } 114 | 115 | h2 { 116 | font-size: calc(1.325rem + 0.9vw); 117 | } 118 | @media (min-width: 1200px) { 119 | h2 { 120 | font-size: 2rem; 121 | } 122 | } 123 | 124 | h3 { 125 | font-size: calc(1.3rem + 0.6vw); 126 | } 127 | @media (min-width: 1200px) { 128 | h3 { 129 | font-size: 1.75rem; 130 | } 131 | } 132 | 133 | h4 { 134 | font-size: calc(1.275rem + 0.3vw); 135 | } 136 | @media (min-width: 1200px) { 137 | h4 { 138 | font-size: 1.5rem; 139 | } 140 | } 141 | 142 | h5 { 143 | font-size: 1.25rem; 144 | } 145 | 146 | h6 { 147 | font-size: 1rem; 148 | } 149 | 150 | p { 151 | margin-top: 0; 152 | margin-bottom: 1rem; 153 | } 154 | 155 | abbr[title], 156 | abbr[data-bs-original-title] { 157 | -webkit-text-decoration: underline dotted; 158 | text-decoration: underline dotted; 159 | cursor: help; 160 | -webkit-text-decoration-skip-ink: none; 161 | text-decoration-skip-ink: none; 162 | } 163 | 164 | address { 165 | margin-bottom: 1rem; 166 | font-style: normal; 167 | line-height: inherit; 168 | } 169 | 170 | ol, 171 | ul { 172 | padding-right: 2rem; 173 | } 174 | 175 | ol, 176 | ul, 177 | dl { 178 | margin-top: 0; 179 | margin-bottom: 1rem; 180 | } 181 | 182 | ol ol, 183 | ul ul, 184 | ol ul, 185 | ul ol { 186 | margin-bottom: 0; 187 | } 188 | 189 | dt { 190 | font-weight: 700; 191 | } 192 | 193 | dd { 194 | margin-bottom: 0.5rem; 195 | margin-right: 0; 196 | } 197 | 198 | blockquote { 199 | margin: 0 0 1rem; 200 | } 201 | 202 | b, 203 | strong { 204 | font-weight: bolder; 205 | } 206 | 207 | small { 208 | font-size: 0.875em; 209 | } 210 | 211 | mark { 212 | padding: 0.2em; 213 | background-color: #fcf8e3; 214 | } 215 | 216 | sub, 217 | sup { 218 | position: relative; 219 | font-size: 0.75em; 220 | line-height: 0; 221 | vertical-align: baseline; 222 | } 223 | 224 | sub { 225 | bottom: -0.25em; 226 | } 227 | 228 | sup { 229 | top: -0.5em; 230 | } 231 | 232 | a { 233 | color: #0d6efd; 234 | text-decoration: underline; 235 | } 236 | a:hover { 237 | color: #0a58ca; 238 | } 239 | 240 | a:not([href]):not([class]), a:not([href]):not([class]):hover { 241 | color: inherit; 242 | text-decoration: none; 243 | } 244 | 245 | pre, 246 | code, 247 | kbd, 248 | samp { 249 | font-family: var(--bs-font-monospace); 250 | font-size: 1em; 251 | direction: ltr ; 252 | unicode-bidi: bidi-override; 253 | } 254 | 255 | pre { 256 | display: block; 257 | margin-top: 0; 258 | margin-bottom: 1rem; 259 | overflow: auto; 260 | font-size: 0.875em; 261 | } 262 | pre code { 263 | font-size: inherit; 264 | color: inherit; 265 | word-break: normal; 266 | } 267 | 268 | code { 269 | font-size: 0.875em; 270 | color: #d63384; 271 | word-wrap: break-word; 272 | } 273 | a > code { 274 | color: inherit; 275 | } 276 | 277 | kbd { 278 | padding: 0.2rem 0.4rem; 279 | font-size: 0.875em; 280 | color: #fff; 281 | background-color: #212529; 282 | border-radius: 0.2rem; 283 | } 284 | kbd kbd { 285 | padding: 0; 286 | font-size: 1em; 287 | font-weight: 700; 288 | } 289 | 290 | figure { 291 | margin: 0 0 1rem; 292 | } 293 | 294 | img, 295 | svg { 296 | vertical-align: middle; 297 | } 298 | 299 | table { 300 | caption-side: bottom; 301 | border-collapse: collapse; 302 | } 303 | 304 | caption { 305 | padding-top: 0.5rem; 306 | padding-bottom: 0.5rem; 307 | color: #6c757d; 308 | text-align: right; 309 | } 310 | 311 | th { 312 | text-align: inherit; 313 | text-align: -webkit-match-parent; 314 | } 315 | 316 | thead, 317 | tbody, 318 | tfoot, 319 | tr, 320 | td, 321 | th { 322 | border-color: inherit; 323 | border-style: solid; 324 | border-width: 0; 325 | } 326 | 327 | label { 328 | display: inline-block; 329 | } 330 | 331 | button { 332 | border-radius: 0; 333 | } 334 | 335 | button:focus:not(:focus-visible) { 336 | outline: 0; 337 | } 338 | 339 | input, 340 | button, 341 | select, 342 | optgroup, 343 | textarea { 344 | margin: 0; 345 | font-family: inherit; 346 | font-size: inherit; 347 | line-height: inherit; 348 | } 349 | 350 | button, 351 | select { 352 | text-transform: none; 353 | } 354 | 355 | [role=button] { 356 | cursor: pointer; 357 | } 358 | 359 | select { 360 | word-wrap: normal; 361 | } 362 | select:disabled { 363 | opacity: 1; 364 | } 365 | 366 | [list]::-webkit-calendar-picker-indicator { 367 | display: none; 368 | } 369 | 370 | button, 371 | [type=button], 372 | [type=reset], 373 | [type=submit] { 374 | -webkit-appearance: button; 375 | } 376 | button:not(:disabled), 377 | [type=button]:not(:disabled), 378 | [type=reset]:not(:disabled), 379 | [type=submit]:not(:disabled) { 380 | cursor: pointer; 381 | } 382 | 383 | ::-moz-focus-inner { 384 | padding: 0; 385 | border-style: none; 386 | } 387 | 388 | textarea { 389 | resize: vertical; 390 | } 391 | 392 | fieldset { 393 | min-width: 0; 394 | padding: 0; 395 | margin: 0; 396 | border: 0; 397 | } 398 | 399 | legend { 400 | float: right; 401 | width: 100%; 402 | padding: 0; 403 | margin-bottom: 0.5rem; 404 | font-size: calc(1.275rem + 0.3vw); 405 | line-height: inherit; 406 | } 407 | @media (min-width: 1200px) { 408 | legend { 409 | font-size: 1.5rem; 410 | } 411 | } 412 | legend + * { 413 | clear: right; 414 | } 415 | 416 | ::-webkit-datetime-edit-fields-wrapper, 417 | ::-webkit-datetime-edit-text, 418 | ::-webkit-datetime-edit-minute, 419 | ::-webkit-datetime-edit-hour-field, 420 | ::-webkit-datetime-edit-day-field, 421 | ::-webkit-datetime-edit-month-field, 422 | ::-webkit-datetime-edit-year-field { 423 | padding: 0; 424 | } 425 | 426 | ::-webkit-inner-spin-button { 427 | height: auto; 428 | } 429 | 430 | [type=search] { 431 | outline-offset: -2px; 432 | -webkit-appearance: textfield; 433 | } 434 | 435 | [type="tel"], 436 | [type="url"], 437 | [type="email"], 438 | [type="number"] { 439 | direction: ltr; 440 | } 441 | ::-webkit-search-decoration { 442 | -webkit-appearance: none; 443 | } 444 | 445 | ::-webkit-color-swatch-wrapper { 446 | padding: 0; 447 | } 448 | 449 | ::file-selector-button { 450 | font: inherit; 451 | } 452 | 453 | ::-webkit-file-upload-button { 454 | font: inherit; 455 | -webkit-appearance: button; 456 | } 457 | 458 | output { 459 | display: inline-block; 460 | } 461 | 462 | iframe { 463 | border: 0; 464 | } 465 | 466 | summary { 467 | display: list-item; 468 | cursor: pointer; 469 | } 470 | 471 | progress { 472 | vertical-align: baseline; 473 | } 474 | 475 | [hidden] { 476 | display: none !important; 477 | } 478 | /*# sourceMappingURL=bootstrap-reboot.rtl.css.map */ -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | :root { 9 | --bs-blue: #0d6efd; 10 | --bs-indigo: #6610f2; 11 | --bs-purple: #6f42c1; 12 | --bs-pink: #d63384; 13 | --bs-red: #dc3545; 14 | --bs-orange: #fd7e14; 15 | --bs-yellow: #ffc107; 16 | --bs-green: #198754; 17 | --bs-teal: #20c997; 18 | --bs-cyan: #0dcaf0; 19 | --bs-white: #fff; 20 | --bs-gray: #6c757d; 21 | --bs-gray-dark: #343a40; 22 | --bs-gray-100: #f8f9fa; 23 | --bs-gray-200: #e9ecef; 24 | --bs-gray-300: #dee2e6; 25 | --bs-gray-400: #ced4da; 26 | --bs-gray-500: #adb5bd; 27 | --bs-gray-600: #6c757d; 28 | --bs-gray-700: #495057; 29 | --bs-gray-800: #343a40; 30 | --bs-gray-900: #212529; 31 | --bs-primary: #0d6efd; 32 | --bs-secondary: #6c757d; 33 | --bs-success: #198754; 34 | --bs-info: #0dcaf0; 35 | --bs-warning: #ffc107; 36 | --bs-danger: #dc3545; 37 | --bs-light: #f8f9fa; 38 | --bs-dark: #212529; 39 | --bs-primary-rgb: 13, 110, 253; 40 | --bs-secondary-rgb: 108, 117, 125; 41 | --bs-success-rgb: 25, 135, 84; 42 | --bs-info-rgb: 13, 202, 240; 43 | --bs-warning-rgb: 255, 193, 7; 44 | --bs-danger-rgb: 220, 53, 69; 45 | --bs-light-rgb: 248, 249, 250; 46 | --bs-dark-rgb: 33, 37, 41; 47 | --bs-white-rgb: 255, 255, 255; 48 | --bs-black-rgb: 0, 0, 0; 49 | --bs-body-color-rgb: 33, 37, 41; 50 | --bs-body-bg-rgb: 255, 255, 255; 51 | --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 52 | --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 53 | --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); 54 | --bs-body-font-family: var(--bs-font-sans-serif); 55 | --bs-body-font-size: 1rem; 56 | --bs-body-font-weight: 400; 57 | --bs-body-line-height: 1.5; 58 | --bs-body-color: #212529; 59 | --bs-body-bg: #fff; 60 | } 61 | 62 | *, 63 | *::before, 64 | *::after { 65 | box-sizing: border-box; 66 | } 67 | 68 | @media (prefers-reduced-motion: no-preference) { 69 | :root { 70 | scroll-behavior: smooth; 71 | } 72 | } 73 | 74 | body { 75 | margin: 0; 76 | font-family: var(--bs-body-font-family); 77 | font-size: var(--bs-body-font-size); 78 | font-weight: var(--bs-body-font-weight); 79 | line-height: var(--bs-body-line-height); 80 | color: var(--bs-body-color); 81 | text-align: var(--bs-body-text-align); 82 | background-color: var(--bs-body-bg); 83 | -webkit-text-size-adjust: 100%; 84 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 85 | } 86 | 87 | hr { 88 | margin: 1rem 0; 89 | color: inherit; 90 | background-color: currentColor; 91 | border: 0; 92 | opacity: 0.25; 93 | } 94 | 95 | hr:not([size]) { 96 | height: 1px; 97 | } 98 | 99 | h6, h5, h4, h3, h2, h1 { 100 | margin-top: 0; 101 | margin-bottom: 0.5rem; 102 | font-weight: 500; 103 | line-height: 1.2; 104 | } 105 | 106 | h1 { 107 | font-size: calc(1.375rem + 1.5vw); 108 | } 109 | @media (min-width: 1200px) { 110 | h1 { 111 | font-size: 2.5rem; 112 | } 113 | } 114 | 115 | h2 { 116 | font-size: calc(1.325rem + 0.9vw); 117 | } 118 | @media (min-width: 1200px) { 119 | h2 { 120 | font-size: 2rem; 121 | } 122 | } 123 | 124 | h3 { 125 | font-size: calc(1.3rem + 0.6vw); 126 | } 127 | @media (min-width: 1200px) { 128 | h3 { 129 | font-size: 1.75rem; 130 | } 131 | } 132 | 133 | h4 { 134 | font-size: calc(1.275rem + 0.3vw); 135 | } 136 | @media (min-width: 1200px) { 137 | h4 { 138 | font-size: 1.5rem; 139 | } 140 | } 141 | 142 | h5 { 143 | font-size: 1.25rem; 144 | } 145 | 146 | h6 { 147 | font-size: 1rem; 148 | } 149 | 150 | p { 151 | margin-top: 0; 152 | margin-bottom: 1rem; 153 | } 154 | 155 | abbr[title], 156 | abbr[data-bs-original-title] { 157 | -webkit-text-decoration: underline dotted; 158 | text-decoration: underline dotted; 159 | cursor: help; 160 | -webkit-text-decoration-skip-ink: none; 161 | text-decoration-skip-ink: none; 162 | } 163 | 164 | address { 165 | margin-bottom: 1rem; 166 | font-style: normal; 167 | line-height: inherit; 168 | } 169 | 170 | ol, 171 | ul { 172 | padding-left: 2rem; 173 | } 174 | 175 | ol, 176 | ul, 177 | dl { 178 | margin-top: 0; 179 | margin-bottom: 1rem; 180 | } 181 | 182 | ol ol, 183 | ul ul, 184 | ol ul, 185 | ul ol { 186 | margin-bottom: 0; 187 | } 188 | 189 | dt { 190 | font-weight: 700; 191 | } 192 | 193 | dd { 194 | margin-bottom: 0.5rem; 195 | margin-left: 0; 196 | } 197 | 198 | blockquote { 199 | margin: 0 0 1rem; 200 | } 201 | 202 | b, 203 | strong { 204 | font-weight: bolder; 205 | } 206 | 207 | small { 208 | font-size: 0.875em; 209 | } 210 | 211 | mark { 212 | padding: 0.2em; 213 | background-color: #fcf8e3; 214 | } 215 | 216 | sub, 217 | sup { 218 | position: relative; 219 | font-size: 0.75em; 220 | line-height: 0; 221 | vertical-align: baseline; 222 | } 223 | 224 | sub { 225 | bottom: -0.25em; 226 | } 227 | 228 | sup { 229 | top: -0.5em; 230 | } 231 | 232 | a { 233 | color: #0d6efd; 234 | text-decoration: underline; 235 | } 236 | a:hover { 237 | color: #0a58ca; 238 | } 239 | 240 | a:not([href]):not([class]), a:not([href]):not([class]):hover { 241 | color: inherit; 242 | text-decoration: none; 243 | } 244 | 245 | pre, 246 | code, 247 | kbd, 248 | samp { 249 | font-family: var(--bs-font-monospace); 250 | font-size: 1em; 251 | direction: ltr /* rtl:ignore */; 252 | unicode-bidi: bidi-override; 253 | } 254 | 255 | pre { 256 | display: block; 257 | margin-top: 0; 258 | margin-bottom: 1rem; 259 | overflow: auto; 260 | font-size: 0.875em; 261 | } 262 | pre code { 263 | font-size: inherit; 264 | color: inherit; 265 | word-break: normal; 266 | } 267 | 268 | code { 269 | font-size: 0.875em; 270 | color: #d63384; 271 | word-wrap: break-word; 272 | } 273 | a > code { 274 | color: inherit; 275 | } 276 | 277 | kbd { 278 | padding: 0.2rem 0.4rem; 279 | font-size: 0.875em; 280 | color: #fff; 281 | background-color: #212529; 282 | border-radius: 0.2rem; 283 | } 284 | kbd kbd { 285 | padding: 0; 286 | font-size: 1em; 287 | font-weight: 700; 288 | } 289 | 290 | figure { 291 | margin: 0 0 1rem; 292 | } 293 | 294 | img, 295 | svg { 296 | vertical-align: middle; 297 | } 298 | 299 | table { 300 | caption-side: bottom; 301 | border-collapse: collapse; 302 | } 303 | 304 | caption { 305 | padding-top: 0.5rem; 306 | padding-bottom: 0.5rem; 307 | color: #6c757d; 308 | text-align: left; 309 | } 310 | 311 | th { 312 | text-align: inherit; 313 | text-align: -webkit-match-parent; 314 | } 315 | 316 | thead, 317 | tbody, 318 | tfoot, 319 | tr, 320 | td, 321 | th { 322 | border-color: inherit; 323 | border-style: solid; 324 | border-width: 0; 325 | } 326 | 327 | label { 328 | display: inline-block; 329 | } 330 | 331 | button { 332 | border-radius: 0; 333 | } 334 | 335 | button:focus:not(:focus-visible) { 336 | outline: 0; 337 | } 338 | 339 | input, 340 | button, 341 | select, 342 | optgroup, 343 | textarea { 344 | margin: 0; 345 | font-family: inherit; 346 | font-size: inherit; 347 | line-height: inherit; 348 | } 349 | 350 | button, 351 | select { 352 | text-transform: none; 353 | } 354 | 355 | [role=button] { 356 | cursor: pointer; 357 | } 358 | 359 | select { 360 | word-wrap: normal; 361 | } 362 | select:disabled { 363 | opacity: 1; 364 | } 365 | 366 | [list]::-webkit-calendar-picker-indicator { 367 | display: none; 368 | } 369 | 370 | button, 371 | [type=button], 372 | [type=reset], 373 | [type=submit] { 374 | -webkit-appearance: button; 375 | } 376 | button:not(:disabled), 377 | [type=button]:not(:disabled), 378 | [type=reset]:not(:disabled), 379 | [type=submit]:not(:disabled) { 380 | cursor: pointer; 381 | } 382 | 383 | ::-moz-focus-inner { 384 | padding: 0; 385 | border-style: none; 386 | } 387 | 388 | textarea { 389 | resize: vertical; 390 | } 391 | 392 | fieldset { 393 | min-width: 0; 394 | padding: 0; 395 | margin: 0; 396 | border: 0; 397 | } 398 | 399 | legend { 400 | float: left; 401 | width: 100%; 402 | padding: 0; 403 | margin-bottom: 0.5rem; 404 | font-size: calc(1.275rem + 0.3vw); 405 | line-height: inherit; 406 | } 407 | @media (min-width: 1200px) { 408 | legend { 409 | font-size: 1.5rem; 410 | } 411 | } 412 | legend + * { 413 | clear: left; 414 | } 415 | 416 | ::-webkit-datetime-edit-fields-wrapper, 417 | ::-webkit-datetime-edit-text, 418 | ::-webkit-datetime-edit-minute, 419 | ::-webkit-datetime-edit-hour-field, 420 | ::-webkit-datetime-edit-day-field, 421 | ::-webkit-datetime-edit-month-field, 422 | ::-webkit-datetime-edit-year-field { 423 | padding: 0; 424 | } 425 | 426 | ::-webkit-inner-spin-button { 427 | height: auto; 428 | } 429 | 430 | [type=search] { 431 | outline-offset: -2px; 432 | -webkit-appearance: textfield; 433 | } 434 | 435 | /* rtl:raw: 436 | [type="tel"], 437 | [type="url"], 438 | [type="email"], 439 | [type="number"] { 440 | direction: ltr; 441 | } 442 | */ 443 | ::-webkit-search-decoration { 444 | -webkit-appearance: none; 445 | } 446 | 447 | ::-webkit-color-swatch-wrapper { 448 | padding: 0; 449 | } 450 | 451 | ::file-selector-button { 452 | font: inherit; 453 | } 454 | 455 | ::-webkit-file-upload-button { 456 | font: inherit; 457 | -webkit-appearance: button; 458 | } 459 | 460 | output { 461 | display: inline-block; 462 | } 463 | 464 | iframe { 465 | border: 0; 466 | } 467 | 468 | summary { 469 | display: list-item; 470 | cursor: pointer; 471 | } 472 | 473 | progress { 474 | vertical-align: baseline; 475 | } 476 | 477 | [hidden] { 478 | display: none !important; 479 | } 480 | 481 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /src/GraphWebhooks/Controllers/ListenController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using GraphWebhooks.Models; 5 | using GraphWebhooks.Services; 6 | using GraphWebhooks.SignalR; 7 | using Microsoft.AspNetCore.Authorization; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.AspNetCore.SignalR; 10 | using Microsoft.Graph; 11 | using Microsoft.Graph.Models; 12 | using Microsoft.Identity.Web; 13 | using Microsoft.Kiota.Abstractions; 14 | using Microsoft.Kiota.Abstractions.Serialization; 15 | using Microsoft.Kiota.Serialization.Json; 16 | 17 | namespace GraphWebhooks.Controllers; 18 | 19 | /// 20 | /// Implements the notification endpoint which receives 21 | /// notifications from Microsoft Graph. 22 | /// 23 | public class ListenController : Controller 24 | { 25 | private readonly GraphServiceClient graphClient; 26 | private readonly SubscriptionStore subscriptionStore; 27 | private readonly CertificateService certificateService; 28 | private readonly IHubContext hubContext; 29 | private readonly ILogger logger; 30 | private readonly List appIds; 31 | private readonly List tenantIds; 32 | 33 | /// 34 | /// Initializes a new instance of the class. 35 | /// 36 | /// The . 37 | /// The subscription store. 38 | /// The certificate service. 39 | /// The SignalR hub context. 40 | /// The app configuration. 41 | /// The logger. 42 | /// Thrown if any parameter is null. 43 | /// Thrown if configuration values are missing. 44 | public ListenController( 45 | GraphServiceClient graphClient, 46 | SubscriptionStore subscriptionStore, 47 | CertificateService certificateService, 48 | IHubContext hubContext, 49 | IConfiguration configuration, 50 | ILogger logger) 51 | { 52 | this.graphClient = graphClient ?? 53 | throw new ArgumentException("GraphServiceClient cannot be null", nameof(graphClient)); 54 | this.subscriptionStore = subscriptionStore ?? 55 | throw new ArgumentException("SubscriptionStore cannot be null", nameof(subscriptionStore)); 56 | this.certificateService = certificateService ?? 57 | throw new ArgumentException("CertificateService cannot be null", nameof(certificateService)); 58 | this.hubContext = hubContext ?? 59 | throw new ArgumentException("IHubContext cannot be null", nameof(hubContext)); 60 | this.logger = logger ?? 61 | throw new ArgumentException("ILogger cannot be null", nameof(logger)); 62 | _ = configuration ?? 63 | throw new ArgumentException("IConfiguration cannot be null", nameof(configuration)); 64 | 65 | var appId = configuration.GetValue("AzureAd:ClientId") is string appIdValue && 66 | !string.IsNullOrEmpty(appIdValue) ? appIdValue : 67 | throw new Exception("AzureAd:ClientId missing in app settings"); 68 | var tenantId = configuration.GetValue("AzureAd:TenantId") is string tenantIdValue && 69 | !string.IsNullOrEmpty(tenantIdValue) ? tenantIdValue : 70 | throw new Exception("AzureAd:TenantId missing in app settings"); 71 | appIds = [new Guid(appId)]; 72 | tenantIds = [new Guid(tenantId)]; 73 | } 74 | 75 | /// 76 | /// POST /listen. 77 | /// 78 | /// Optional. Validation token sent by Microsoft Graph during endpoint validation phase. 79 | /// An . 80 | [HttpPost] 81 | [AllowAnonymous] 82 | public async Task Index([FromQuery] string? validationToken = null) 83 | { 84 | // If there is a validation token in the query string, 85 | // send it back in a 200 OK text/plain response 86 | if (!string.IsNullOrEmpty(validationToken)) 87 | { 88 | return Ok(validationToken); 89 | } 90 | 91 | // Use the Graph client's serializer to deserialize the body 92 | using var bodyStream = new MemoryStream(); 93 | await Request.Body.CopyToAsync(bodyStream); 94 | bodyStream.Seek(0, SeekOrigin.Begin); 95 | 96 | // Calling RegisterDefaultDeserializer here isn't strictly necessary since 97 | // we have a GraphServiceClient instance. In cases where you do not have a 98 | // GraphServiceClient, you need to register the JSON provider before trying 99 | // to deserialize. 100 | ApiClientBuilder.RegisterDefaultDeserializer(); 101 | var notifications = KiotaJsonSerializer.Deserialize(bodyStream); 102 | 103 | if (notifications == null || notifications.Value == null) 104 | { 105 | return Accepted(); 106 | } 107 | 108 | // Validate any tokens in the payload 109 | var areTokensValid = await notifications.AreTokensValid(tenantIds, appIds); 110 | if (!areTokensValid) 111 | { 112 | return Unauthorized(); 113 | } 114 | 115 | // Process non-encrypted notifications first 116 | // These will be notifications for user mailbox 117 | var messageNotifications = new Dictionary(); 118 | foreach (var notification in notifications.Value.Where(n => n.EncryptedContent == null)) 119 | { 120 | // Find the subscription in our store 121 | var subscription = subscriptionStore 122 | .GetSubscriptionRecord(notification.SubscriptionId.ToString() ?? string.Empty); 123 | 124 | // If this isn't a subscription we know about, or if client state doesn't match, 125 | // ignore it 126 | if (subscription != null && subscription.ClientState == notification.ClientState) 127 | { 128 | logger.LogInformation("Received notification for: {resource}", notification.Resource); 129 | 130 | // Add notification to list to process. If there is more than 131 | // one notification for a given resource, we'll only process it once 132 | messageNotifications[notification.Resource!] = notification; 133 | } 134 | } 135 | 136 | // Since resource data is not included in these notifications, 137 | // use Microsoft Graph to get the messages 138 | await GetMessagesAsync(messageNotifications.Values); 139 | 140 | // Process encrypted notifications 141 | var clientNotifications = new List(); 142 | foreach (var notification in notifications.Value.Where(n => n.EncryptedContent != null)) 143 | { 144 | // Decrypt the encrypted payload using private key 145 | try 146 | { 147 | var chatMessage = await notification.EncryptedContent!.DecryptAsync(async (id, thumbprint) => 148 | { 149 | var cert = await certificateService.GetDecryptionCertificate(); 150 | return cert; 151 | }); 152 | 153 | // Add a SignalR notification for this message to the list 154 | if (chatMessage != null) 155 | { 156 | clientNotifications.Add(new ClientNotification(new 157 | { 158 | Sender = chatMessage.From?.User?.DisplayName ?? "UNKNOWN", 159 | Message = chatMessage.Body?.Content ?? string.Empty, 160 | })); 161 | } 162 | } 163 | catch (Exception ex) 164 | { 165 | logger.LogError(ex, "{message}", ex.Message); 166 | throw; 167 | } 168 | } 169 | 170 | // Send SignalR notifications 171 | if (clientNotifications.Count > 0) 172 | { 173 | await hubContext.Clients.All.SendAsync("showNotification", clientNotifications); 174 | } 175 | 176 | // Return 202 to Graph to confirm receipt of notification. 177 | // Not sending this will cause Graph to retry the notification. 178 | return Accepted(); 179 | } 180 | 181 | /// 182 | /// Gets each message specified in a set of notifications. 183 | /// 184 | /// A set of notifications for new messages. 185 | private async Task GetMessagesAsync(IEnumerable notifications) 186 | { 187 | var clientNotifications = new List(); 188 | 189 | foreach (var notification in notifications) 190 | { 191 | // Get the subscription from the store for user/tenant ID 192 | var subscription = subscriptionStore.GetSubscriptionRecord(notification.SubscriptionId.ToString() ?? string.Empty); 193 | 194 | if (subscription != null && 195 | !string.IsNullOrEmpty(subscription.UserId) && 196 | !string.IsNullOrEmpty(subscription.TenantId)) 197 | { 198 | // Since the POST comes from Graph, there's no user in the context 199 | // Set the user to the user that owns the message. This will enable 200 | // Microsoft.Identity.Web to acquire the proper token for the proper user 201 | HttpContext.User = ClaimsPrincipalFactory 202 | .FromTenantIdAndObjectId(subscription.TenantId, subscription.UserId); 203 | HttpContext.User.AddMsalInfo(subscription.UserId, subscription.TenantId); 204 | } 205 | 206 | // The notification has the relative URL to the message in the Resource 207 | // property, so build the request using that information 208 | var request = new RequestInformation 209 | { 210 | HttpMethod = Method.GET, 211 | URI = new Uri($"{graphClient.RequestAdapter.BaseUrl}/{notification.Resource}"), 212 | }; 213 | 214 | var message = await graphClient.RequestAdapter.SendAsync(request, Message.CreateFromDiscriminatorValue); 215 | 216 | // Add a SignalR notification for the message 217 | clientNotifications.Add(new ClientNotification(new 218 | { 219 | Subject = message?.Subject ?? string.Empty, 220 | Id = message?.Id ?? string.Empty, 221 | })); 222 | } 223 | 224 | // Send SignalR notifications 225 | if (clientNotifications.Count > 0) 226 | { 227 | await hubContext.Clients.All.SendAsync("showNotification", clientNotifications); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/GraphWebhooks/Controllers/WatchController.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | using GraphWebhooks.Models; 5 | using GraphWebhooks.Services; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Graph; 8 | using Microsoft.Graph.Models; 9 | using Microsoft.Identity.Web; 10 | 11 | namespace GraphWebhooks.Controllers; 12 | 13 | /// 14 | /// Implements subscription management endpoints. 15 | /// 16 | public class WatchController : Controller 17 | { 18 | private readonly GraphServiceClient graphClient; 19 | private readonly SubscriptionStore subscriptionStore; 20 | private readonly CertificateService certificateService; 21 | private readonly ILogger logger; 22 | private readonly string notificationHost; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// The . 28 | /// The subscription store. 29 | /// The certificate service. 30 | /// The logger. 31 | /// The app configuration. 32 | /// Thrown if any parameter is null. 33 | public WatchController( 34 | GraphServiceClient graphClient, 35 | SubscriptionStore subscriptionStore, 36 | CertificateService certificateService, 37 | ILogger logger, 38 | IConfiguration configuration) 39 | { 40 | this.graphClient = graphClient ?? 41 | throw new ArgumentException("GraphServiceClient cannot be null", nameof(graphClient)); 42 | this.subscriptionStore = subscriptionStore ?? 43 | throw new ArgumentException("SubscriptionStore cannot be null", nameof(subscriptionStore)); 44 | this.certificateService = certificateService ?? 45 | throw new ArgumentException("CertificateService cannot be null", nameof(certificateService)); 46 | this.logger = logger ?? 47 | throw new ArgumentException("ILogger cannot be null", nameof(logger)); 48 | _ = configuration ?? 49 | throw new ArgumentException("IConfiguration cannot be null", nameof(configuration)); 50 | 51 | notificationHost = configuration.GetValue("NotificationHost") is string hostValue && 52 | !string.IsNullOrEmpty(hostValue) && !hostValue.Equals("YOUR_NGROK_PROXY", StringComparison.OrdinalIgnoreCase) ? hostValue : 53 | throw new ArgumentException("You must configure NotificationHost in appsettings.json"); 54 | } 55 | 56 | /// 57 | /// GET /watch/delegated 58 | /// Creates a new subscription to the authenticated user's inbox and 59 | /// displays a page that updates with each received notification. 60 | /// 61 | /// An . 62 | [AuthorizeForScopes(ScopeKeySection = "GraphScopes")] 63 | public async Task Delegated() 64 | { 65 | try 66 | { 67 | // Delete any existing subscriptions for the user 68 | await DeleteAllSubscriptions(false); 69 | 70 | // Get the user's ID and tenant ID from the user's identity 71 | var userId = User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; 72 | logger.LogInformation("Authenticated user ID {userId}", userId); 73 | var tenantId = User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; 74 | 75 | // Get the user from Microsoft Graph 76 | var user = await graphClient.Me.GetAsync(req => 77 | { 78 | req.QueryParameters.Select = ["displayName", "mail", "userPrincipalName"]; 79 | }); 80 | 81 | if (user == null) 82 | { 83 | logger.LogWarning("Could not retrieve authenticated user."); 84 | return View().WithError("Could not retrieve authenticated user."); 85 | } 86 | 87 | logger.LogInformation( 88 | "Authenticated user: {displayName} ({email})", 89 | user.DisplayName, 90 | user.Mail ?? user.UserPrincipalName); 91 | 92 | // Add the user's display name and email address to the user's 93 | // identity. 94 | User.AddUserGraphInfo(user); 95 | 96 | // Create the subscription 97 | var subscription = new Subscription 98 | { 99 | ChangeType = "created", 100 | NotificationUrl = $"{notificationHost}/listen", 101 | LifecycleNotificationUrl = $"{notificationHost}/lifecycle", 102 | Resource = "me/mailfolders/inbox/messages", 103 | ClientState = Guid.NewGuid().ToString(), 104 | IncludeResourceData = false, 105 | 106 | // Subscription only lasts for one hour 107 | ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1), 108 | }; 109 | 110 | var newSubscription = await graphClient.Subscriptions 111 | .PostAsync(subscription); 112 | 113 | if (newSubscription == null) 114 | { 115 | return View().WithError("No subscription was returned."); 116 | } 117 | 118 | // Add the subscription to the subscription store 119 | subscriptionStore.SaveSubscriptionRecord(new SubscriptionRecord 120 | { 121 | Id = newSubscription.Id, 122 | UserId = userId, 123 | TenantId = tenantId, 124 | ClientState = newSubscription.ClientState, 125 | }); 126 | 127 | return View(newSubscription).WithSuccess("Subscription created"); 128 | } 129 | catch (Exception ex) 130 | { 131 | // Throw MicrosoftIdentityWebChallengeUserException to allow 132 | // Microsoft.Identity.Web to challenge the user for re-auth or consent 133 | if (ex.InnerException is MicrosoftIdentityWebChallengeUserException) 134 | { 135 | throw; 136 | } 137 | 138 | // Otherwise display the error 139 | return View().WithError( 140 | $"Error creating subscription: {ex.Message}", 141 | ex.ToString()); 142 | } 143 | } 144 | 145 | /// 146 | /// GET /watch/apponly 147 | /// Creates a new subscription to all Teams channel messages and 148 | /// displays a page that updates with each received notification. 149 | /// 150 | /// An . 151 | public async Task AppOnly() 152 | { 153 | try 154 | { 155 | // Delete any existing Teams channel subscriptions 156 | // This is important as each app is only allowed one active 157 | // subscription to the /teams/getAllMessages resource 158 | await DeleteAllSubscriptions(true); 159 | 160 | var tenantId = User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; 161 | 162 | // Get the encryption certificate (public key) 163 | var encryptionCertificate = await certificateService.GetEncryptionCertificate(); 164 | 165 | // Create the subscription 166 | var subscription = new Subscription 167 | { 168 | ChangeType = "created", 169 | NotificationUrl = $"{notificationHost}/listen", 170 | LifecycleNotificationUrl = $"{notificationHost}/lifecycle", 171 | Resource = "/teams/getAllMessages", 172 | ClientState = Guid.NewGuid().ToString(), 173 | IncludeResourceData = true, 174 | ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1), 175 | 176 | // To get resource data, we must provide a public key that 177 | // Microsoft Graph will use to encrypt their key 178 | // See https://docs.microsoft.com/graph/webhooks-with-resource-data#creating-a-subscription 179 | EncryptionCertificateId = encryptionCertificate.Subject, 180 | }; 181 | 182 | subscription.AddPublicEncryptionCertificate(encryptionCertificate); 183 | 184 | var newSubscription = await graphClient.Subscriptions.PostAsync(subscription, req => 185 | { 186 | req.Options.WithAppOnly(); 187 | }); 188 | 189 | if (newSubscription == null) 190 | { 191 | return RedirectToAction("Index", "Home") 192 | .WithError("No subscription was returned."); 193 | } 194 | 195 | // Add the subscription to the subscription store 196 | subscriptionStore.SaveSubscriptionRecord(new SubscriptionRecord 197 | { 198 | Id = newSubscription.Id, 199 | UserId = "APP-ONLY", 200 | TenantId = tenantId, 201 | ClientState = newSubscription.ClientState, 202 | }); 203 | 204 | return View(newSubscription).WithSuccess("Subscription created"); 205 | } 206 | catch (Exception ex) 207 | { 208 | return RedirectToAction("Index", "Home") 209 | .WithError( 210 | $"Error creating subscription: {ex.Message}", 211 | ex.ToString()); 212 | } 213 | } 214 | 215 | /// 216 | /// GET /watch/unsubscribe 217 | /// Deletes the user's inbox subscription and signs the user out. 218 | /// 219 | /// The ID of the subscription to delete. 220 | /// An . 221 | public async Task Unsubscribe(string subscriptionId) 222 | { 223 | if (string.IsNullOrEmpty(subscriptionId)) 224 | { 225 | return RedirectToAction("Index", "Home") 226 | .WithError("No subscription ID specified"); 227 | } 228 | 229 | try 230 | { 231 | var subscription = subscriptionStore.GetSubscriptionRecord(subscriptionId); 232 | 233 | if (subscription != null) 234 | { 235 | var appOnly = subscription.UserId == "APP-ONLY"; 236 | 237 | // To unsubscribe, just delete the subscription 238 | await graphClient.Subscriptions[subscriptionId] 239 | .DeleteAsync(req => 240 | { 241 | req.Options.WithAppOnly(appOnly); 242 | }); 243 | 244 | // Remove the subscription from the subscription store 245 | subscriptionStore.DeleteSubscriptionRecord(subscriptionId); 246 | } 247 | } 248 | catch (Exception ex) 249 | { 250 | // Throw MicrosoftIdentityWebChallengeUserException to allow 251 | // Microsoft.Identity.Web to challenge the user for re-auth or consent 252 | if (ex.InnerException is MicrosoftIdentityWebChallengeUserException) 253 | { 254 | throw; 255 | } 256 | 257 | // Otherwise log the error 258 | logger.LogError(ex, "Error deleting subscription"); 259 | } 260 | 261 | // Redirect to Microsoft.Identity.Web's signout page 262 | return RedirectToAction("SignOut", "Account", new { area = "MicrosoftIdentity" }); 263 | } 264 | 265 | /// 266 | /// Deletes all current subscriptions. 267 | /// 268 | /// If true, all app-only subscriptions are removed. If false, all user subscriptions are removed. 269 | private async Task DeleteAllSubscriptions(bool appOnly) 270 | { 271 | try 272 | { 273 | // Get all current subscriptions 274 | var subscriptions = await graphClient.Subscriptions 275 | .GetAsync(req => 276 | { 277 | req.Options.WithAppOnly(appOnly); 278 | }); 279 | 280 | foreach (var subscription in subscriptions?.Value ?? []) 281 | { 282 | // Delete the subscription 283 | await graphClient.Subscriptions[subscription.Id] 284 | .DeleteAsync(req => 285 | { 286 | req.Options.WithAppOnly(appOnly); 287 | }); 288 | 289 | // Remove the subscription from the subscription store 290 | subscriptionStore.DeleteSubscriptionRecord(subscription.Id!); 291 | } 292 | } 293 | catch (Exception ex) 294 | { 295 | logger.LogError(ex, "Error deleting existing subscriptions"); 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | description: Create Microsoft Graph webhook subscriptions for an ASP.NET Core app, so that it can receive notifications of changes for any resource. This sample also supports receiving change notifications with data, validating and decrypting the payload. 4 | products: 5 | - ms-graph 6 | - office-exchange-online 7 | - entra 8 | - entra-id 9 | - office-teams 10 | languages: 11 | - aspx-csharp 12 | - csharp 13 | extensions: 14 | contentType: samples 15 | technologies: 16 | - Microsoft Graph 17 | services: 18 | - Microsoft Teams 19 | - Azure AD 20 | - Office 365 21 | - Change notifications 22 | createdDate: 3/3/2017 8:55:02 AM 23 | --- 24 | 25 | # Microsoft Graph Change Notifications Sample for ASP.NET Core 26 | 27 | ![.NET Core](https://github.com/microsoftgraph/aspnetcore-webhooks-sample/workflows/.NET%20Core/badge.svg?branch=main) 28 | 29 | Subscribe for [Microsoft Graph change notifications](https://learn.microsoft.com/graph/api/resources/change-notifications-api-overview) to be notified when your user's data changes, so you don't have to poll for changes. 30 | 31 | This sample ASP.NET Core web application shows how to subscribe for change notifications as well as how to validate and decrypt change notifications with resource data when supported by the resource. 32 | 33 | This sample uses: 34 | 35 | - The [Microsoft Graph Client Library for .NET](https://github.com/microsoftgraph/msgraph-sdk-dotnet) (SDK) to call Microsoft Graph. 36 | - The [Microsoft.Identity.Web](https://github.com/AzureAD/microsoft-identity-web) library to abstract token acquisition. 37 | 38 | ## Using the Microsoft Graph Webhooks Sample 39 | 40 | This sample implements the following scenarios. 41 | 42 | - Uses user-delegated authentication to subscribe to notifications in a user's Exchange Online inbox. 43 | - Uses app-only authentication to subscribe to notifications for all new Teams channel messages. These notifications include encrypted resource data. 44 | 45 | [User-delegated authentication](https://learn.microsoft.com/graph/auth-v2-user) represents a user and the application being used when calling the Microsoft Graph. This type of authentication is best suited for scenarios when the user interacts with the application. [Application only authentication](https://learn.microsoft.com/graph/auth-v2-service) represents only the application itself when calling the Microsoft Graph, without any notion of user. This type of authentication is best suited for background services, daemons or other kind of applications users are not directly interacting with. 46 | 47 | > See the list of [permissions and authentication types](https://learn.microsoft.com/graph/api/subscription-post-subscriptions?view=graph-rest-1.0) permitted for each supported resource in Microsoft Graph. 48 | 49 | ### Prerequisites 50 | 51 | To use the Microsoft Graph Webhook Sample for ASP.NET Core, you need the following: 52 | 53 | - [.NET 8.0](https://dotnet.microsoft.com/download) or later. 54 | - A [work, or school account](https://developer.microsoft.com/microsoft-365/dev-program). **Note:** The app-only Teams channel scenario in the sample requires a tenant administrator account to grant application permissions for the app-only portion. 55 | - The application ID and secret from the application that you [register on the Azure Portal](#create-the-app-registration). 56 | - A public HTTPS endpoint to receive and send HTTP requests. You can host this on Microsoft Azure or another service, or you can [use ngrok](#set-up-the-ngrok-proxy-optional) or a similar tool while testing. 57 | - If you are also testing change notifications with resource data, you also need a Microsoft Azure subscription to create an Azure Key Vault. If you do not have a Microsoft Azure subscription, you can [start a free trial](https://azure.microsoft.com/free/). 58 | 59 | ### Create the app registration 60 | 61 | #### Choose the tenant where you want to create your app 62 | 63 | 1. Sign in to the [Azure portal](https://portal.azure.com) using either a work or school account. 64 | 1. If your account is present in more than one Azure AD tenant: 65 | 1. Select your profile from the menu on the top right corner of the page, and then **Switch directory**. 66 | 1. Change your session to the Azure AD tenant where you want to create your application. 67 | 68 | #### Register the app 69 | 70 | 1. Select **Microsoft Entra ID** in the left-hand navigation, then select [App registrations](https://go.microsoft.com/fwlink/?linkid=2083908) under **Manage**. 71 | 72 | 1. Select **New registration**. On the **Register an application** page, set the values as follows. 73 | 74 | - Set **Name** to `ASP.NET Graph Notification Webhook Sample`. 75 | - Set **Supported account types** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts**. 76 | - Under **Redirect URI**, set the first drop-down to `Web` and set the value to `https://localhost:5001/`. 77 | 78 | ![A screenshot of the Register an application page](images/register-an-app.png) 79 | 80 | 1. Select **Register** to create the app. On the app's **Overview** page, copy the value of the **Application (client) ID** and **Directory (tenant) ID** and save them for later. 81 | 82 | 1. Select **Authentication** under **Manage**. Add an additional **Redirect URI** with the value `https://localhost:5001/signin-oidc`. 83 | 84 | 1. Set the **Front-channel logout URL** to `https://localhost:5001/signout-oidc`. Select **Save**. 85 | 86 | 1. Select **Certificates & secrets** under **Manage**. Select the **New client secret** button. Enter a value in **Description** and select one of the options for **Expires** and select **Add**. 87 | 88 | 1. Copy the **Value** of the new secret **before** you leave this page. It will never be displayed again. Save the value for later. 89 | 90 | ![A screenshot of a new secret in the Client secrets list](images/copy-secret-value.png) 91 | 92 | 1. Select **API permissions** under **Manage**. 93 | 94 | 1. In the list of pages for the app, select **API permissions**, then select **Add a permission**. 95 | 96 | 1. Make sure that the **Microsoft APIs** tab is selected, then select **Microsoft Graph**. 97 | 98 | 1. Select **Application permissions**, then find and enable the **ChannelMessage.Read.All** permission. Select **Add permissions** to add the enabled permission. 99 | 100 | > **Note:** To create subscriptions for other resources you need to select different permissions as documented [here](https://learn.microsoft.com/graph/api/subscription-post-subscriptions#permissions) 101 | 102 | 1. In the **Configured permissions** list, select the ellipses (`...`) in the **User.Read** row, and select **Remove permission**. The **User.Read** permission will be requested dynamically as part of the user sign-in process. 103 | 104 | ![A screenshot of the Remove permission menu item](images/remove-configured-permission.png) 105 | 106 | 1. Select **Grant admin consent for `name of your organization`** and **Yes**. This grants consent to the permissions of the application registration you just created to the current organization. 107 | 108 | ### Setting up Azure Key Vault 109 | 110 | > [!NOTE] 111 | > The app-only scenario in this sample requires this step. It assumes that the public/private key pair for encrypting and decrypting resource data in the notification payloads are stored in Azure Key Vault. Refer to [the documentation](https://learn.microsoft.com/graph/api/resources/change-notifications-api-overview) for a complete list of resources that support including resources data. 112 | 113 | Follow the [documented steps](./KEYVAULT.md) to configure your Azure KeyVault in order to add support for change notifications with resource data. 114 | 115 | ### Set up the ngrok proxy (optional) 116 | 117 | You must expose a public HTTPS endpoint to create a subscription and receive notifications from Microsoft Graph. While testing, you can use ngrok to temporarily allow messages from Microsoft Graph to tunnel to a *localhost* port on your computer. 118 | 119 | You can use the ngrok web interface (`http://127.0.0.1:4040`) to inspect the HTTP traffic that passes through the tunnel. To learn more about using ngrok, see the [ngrok website](https://ngrok.com/). 120 | 121 | 1. [Download ngrok](https://ngrok.com/download). 122 | 123 | 1. Run the following command in your command-line interface. 124 | 125 | ```Shell 126 | ngrok http https://localhost:5001 127 | ``` 128 | 129 | 1. Copy the HTTPS URL that's shown in the console. You'll use this to configure your notification URL in the sample. 130 | 131 | ![The forwarding HTTPS URL in the ngrok console](images/ngrok-https-url.png) 132 | 133 | Keep the console open while testing. If you close it, the tunnel also closes and you'll need to generate a new URL and update the sample. 134 | 135 | > See [troubleshooting](./TROUBLESHOOTING.md) for more information about using tunnels. 136 | 137 | ## Configure and run the sample 138 | 139 | 1. Expose a public HTTPS notification endpoint. It can run on a service such as Microsoft Azure, or you can create a proxy web server by [using ngrok](#set-up-the-ngrok-proxy-optional) or a similar tool. 140 | 141 | 1. Open **appsettings.json** in the root directory of the project. 142 | 143 | > **Note:** During development, it's recommended that you use the [.NET Secret Manager](https://learn.microsoft.com/aspnet/core/security/app-secrets#secret-manager) to store secrets instead of putting them in appsettings.json. 144 | 145 | - Settings under **AzureAd**: 146 | - **TenantId**: Your tenant ID (obtained when registering the application) 147 | - **ClientId**: Your application ID (obtained when registering the application) 148 | - **ClientSecret**: Your client secret (obtained when registering the application) **RECOMMENDED: set this in Secret Manager instead:** `dotnet user-secrets set AzureAd:ClientSecret "YOUR_CLIENT_SECRET"`. 149 | - **GraphScopes**: The Graph permission scopes used in the user-delegated scenario. These are already set for the user's inbox scenario. You'll need to change these if you subscribe to a different resource. 150 | - **NotificationHost**: Set to the host name of the server that hosts your application. During local development, set this to your ngrok URL. 151 | - Settings under **KeyVault**: 152 | - **Url**: The URL to your Azure Key Vault 153 | - **CertificateName** The name of the certificate in your Azure Key Vault 154 | 155 | 1. Make sure that the ngrok console is still running, then run the app with the following command. 156 | 157 | ```Shell 158 | dotnet run 159 | ``` 160 | 161 | > **Note:** You can use [Visual Studio Code](https://code.visualstudio.com/) to set breakpoints and run the sample in debug mode. 162 | 163 | ### Use the app to create a subscription 164 | 165 | #### Use delegated authentication to subscribe to a user's inbox 166 | 167 | 1. Choose the **Sign in and subscribe** button and sign in with a work or school account. 168 | 169 | 1. Review and consent to the requested permissions. The subscription is created and you are redirected to a page displaying any notification being received. 170 | 171 | 1. Send an email to yourself. A notification appears showing the subject and message ID. 172 | 173 | ![A screenshot of the user inbox notifications page](images/user-inbox-notifications.png) 174 | 175 | #### Use app-only authentication to subscribe to Teams channel messages 176 | 177 | 1. If you previously subscribed to a user's inbox, choose the **Delete subscription** button to return to the home page. 178 | 179 | 1. Choose the **Subscribe** button. The subscription is created and you are redirected to a page displaying any notification being received. 180 | 181 | 1. Post a message to a channel in any team in Microsoft Teams. A notification appears showing the sender's name and the message. 182 | 183 | ![A screenshot of the Teams channel notifications page](images/teams-channel-notifications.png) 184 | 185 | ## Troubleshooting 186 | 187 | See the dedicated [troubleshooting page](./TROUBLESHOOTING.md). 188 | 189 | ## Contributing 190 | 191 | If you'd like to contribute to this sample, see [CONTRIBUTING.MD](./CONTRIBUTING.md). 192 | 193 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 194 | 195 | ## Questions and comments 196 | 197 | We'd love to get your feedback about the Microsoft Graph Webhooks sample for ASP.NET Core. You can send your questions and suggestions to us in the [Issues](https://github.com/microsoftgraph/aspnetcore-webhooks-sample/issues) section of this repository. 198 | 199 | Questions about Microsoft Graph in general should be posted to [Microsoft Q&A](https://learn.microsoft.com/answers/products/graph). Make sure that your questions or comments are tagged with the relevant Microsoft Graph tag. 200 | 201 | ## Additional resources 202 | 203 | - [Microsoft Graph Webhooks sample for Node.js](https://github.com/microsoftgraph/nodejs-webhooks-sample) 204 | - [Microsoft Graph Webhooks sample for Java Spring](https://github.com/microsoftgraph/java-spring-webhooks-sample) 205 | - [Working with Webhooks in Microsoft Graph](https://learn.microsoft.com/graph/api/resources/change-notifications-api-overview) 206 | - [Subscription resource](https://learn.microsoft.com/graph/api/resources/subscription) 207 | - [Microsoft Graph documentation](https://learn.microsoft.com/graph) 208 | -------------------------------------------------------------------------------- /src/GraphWebhooks/wwwroot/lib/jquery-validation/dist/additional-methods.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery Validation Plugin - v1.17.0 - 7/29/2017 2 | * https://jqueryvalidation.org/ 3 | * Copyright (c) 2017 Jörn Zaefferer; Licensed MIT */ 4 | !function(a){"function"==typeof define&&define.amd?define(["jquery","./jquery.validate.min"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery")):a(jQuery)}(function(a){return function(){function b(a){return a.replace(/<.[^<>]*?>/g," ").replace(/ | /gi," ").replace(/[.(),;:!?%#$'\"_+=\/\-“”’]*/g,"")}a.validator.addMethod("maxWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length<=d},a.validator.format("Please enter {0} words or less.")),a.validator.addMethod("minWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length>=d},a.validator.format("Please enter at least {0} words.")),a.validator.addMethod("rangeWords",function(a,c,d){var e=b(a),f=/\b\w+\b/g;return this.optional(c)||e.match(f).length>=d[0]&&e.match(f).length<=d[1]},a.validator.format("Please enter between {0} and {1} words."))}(),a.validator.addMethod("accept",function(b,c,d){var e,f,g,h="string"==typeof d?d.replace(/\s/g,""):"image/*",i=this.optional(c);if(i)return i;if("file"===a(c).attr("type")&&(h=h.replace(/[\-\[\]\/\{\}\(\)\+\?\.\\\^\$\|]/g,"\\$&").replace(/,/g,"|").replace(/\/\*/g,"/.*"),c.files&&c.files.length))for(g=new RegExp(".?("+h+")$","i"),e=0;e9?"0":f,g="JABCDEFGHI".substr(f,1).toString(),i.match(/[ABEH]/)?k===f:i.match(/[KPQS]/)?k===g:k===f||k===g},"Please specify a valid CIF number."),a.validator.addMethod("cpfBR",function(a){if(a=a.replace(/([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g,""),11!==a.length)return!1;var b,c,d,e,f=0;if(b=parseInt(a.substring(9,10),10),c=parseInt(a.substring(10,11),10),d=function(a,b){var c=10*a%11;return 10!==c&&11!==c||(c=0),c===b},""===a||"00000000000"===a||"11111111111"===a||"22222222222"===a||"33333333333"===a||"44444444444"===a||"55555555555"===a||"66666666666"===a||"77777777777"===a||"88888888888"===a||"99999999999"===a)return!1;for(e=1;e<=9;e++)f+=parseInt(a.substring(e-1,e),10)*(11-e);if(d(f,b)){for(f=0,e=1;e<=10;e++)f+=parseInt(a.substring(e-1,e),10)*(12-e);return d(f,c)}return!1},"Please specify a valid CPF number"),a.validator.addMethod("creditcard",function(a,b){if(this.optional(b))return"dependency-mismatch";if(/[^0-9 \-]+/.test(a))return!1;var c,d,e=0,f=0,g=!1;if(a=a.replace(/\D/g,""),a.length<13||a.length>19)return!1;for(c=a.length-1;c>=0;c--)d=a.charAt(c),f=parseInt(d,10),g&&(f*=2)>9&&(f-=9),e+=f,g=!g;return e%10===0},"Please enter a valid credit card number."),a.validator.addMethod("creditcardtypes",function(a,b,c){if(/[^0-9\-]+/.test(a))return!1;a=a.replace(/\D/g,"");var d=0;return c.mastercard&&(d|=1),c.visa&&(d|=2),c.amex&&(d|=4),c.dinersclub&&(d|=8),c.enroute&&(d|=16),c.discover&&(d|=32),c.jcb&&(d|=64),c.unknown&&(d|=128),c.all&&(d=255),1&d&&/^(5[12345])/.test(a)?16===a.length:2&d&&/^(4)/.test(a)?16===a.length:4&d&&/^(3[47])/.test(a)?15===a.length:8&d&&/^(3(0[012345]|[68]))/.test(a)?14===a.length:16&d&&/^(2(014|149))/.test(a)?15===a.length:32&d&&/^(6011)/.test(a)?16===a.length:64&d&&/^(3)/.test(a)?16===a.length:64&d&&/^(2131|1800)/.test(a)?15===a.length:!!(128&d)},"Please enter a valid credit card number."),a.validator.addMethod("currency",function(a,b,c){var d,e="string"==typeof c,f=e?c:c[0],g=!!e||c[1];return f=f.replace(/,/g,""),f=g?f+"]":f+"]?",d="^["+f+"([1-9]{1}[0-9]{0,2}(\\,[0-9]{3})*(\\.[0-9]{0,2})?|[1-9]{1}[0-9]{0,}(\\.[0-9]{0,2})?|0(\\.[0-9]{0,2})?|(\\.[0-9]{1,2})?)$",d=new RegExp(d),this.optional(b)||d.test(a)},"Please specify a valid currency"),a.validator.addMethod("dateFA",function(a,b){return this.optional(b)||/^[1-4]\d{3}\/((0?[1-6]\/((3[0-1])|([1-2][0-9])|(0?[1-9])))|((1[0-2]|(0?[7-9]))\/(30|([1-2][0-9])|(0?[1-9]))))$/.test(a)},a.validator.messages.date),a.validator.addMethod("dateITA",function(a,b){var c,d,e,f,g,h=!1,i=/^\d{1,2}\/\d{1,2}\/\d{4}$/;return i.test(a)?(c=a.split("/"),d=parseInt(c[0],10),e=parseInt(c[1],10),f=parseInt(c[2],10),g=new Date(Date.UTC(f,e-1,d,12,0,0,0)),h=g.getUTCFullYear()===f&&g.getUTCMonth()===e-1&&g.getUTCDate()===d):h=!1,this.optional(b)||h},a.validator.messages.date),a.validator.addMethod("dateNL",function(a,b){return this.optional(b)||/^(0?[1-9]|[12]\d|3[01])[\.\/\-](0?[1-9]|1[012])[\.\/\-]([12]\d)?(\d\d)$/.test(a)},a.validator.messages.date),a.validator.addMethod("extension",function(a,b,c){return c="string"==typeof c?c.replace(/,/g,"|"):"png|jpe?g|gif",this.optional(b)||a.match(new RegExp("\\.("+c+")$","i"))},a.validator.format("Please enter a value with a valid extension.")),a.validator.addMethod("giroaccountNL",function(a,b){return this.optional(b)||/^[0-9]{1,7}$/.test(a)},"Please specify a valid giro account number"),a.validator.addMethod("iban",function(a,b){if(this.optional(b))return!0;var c,d,e,f,g,h,i,j,k,l=a.replace(/ /g,"").toUpperCase(),m="",n=!0,o="",p="",q=5;if(l.length9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)7(?:[1345789]\d{2}|624)\s?\d{3}\s?\d{3})$/)},"Please specify a valid mobile number"),a.validator.addMethod("netmask",function(a,b){return this.optional(b)||/^(254|252|248|240|224|192|128)\.0\.0\.0|255\.(254|252|248|240|224|192|128|0)\.0\.0|255\.255\.(254|252|248|240|224|192|128|0)\.0|255\.255\.255\.(254|252|248|240|224|192|128|0)/i.test(a)},"Please enter a valid netmask."),a.validator.addMethod("nieES",function(a,b){"use strict";if(this.optional(b))return!0;var c,d=new RegExp(/^[MXYZ]{1}[0-9]{7,8}[TRWAGMYFPDXBNJZSQVHLCKET]{1}$/gi),e="TRWAGMYFPDXBNJZSQVHLCKET",f=a.substr(a.length-1).toUpperCase();return a=a.toString().toUpperCase(),!(a.length>10||a.length<9||!d.test(a))&&(a=a.replace(/^[X]/,"0").replace(/^[Y]/,"1").replace(/^[Z]/,"2"),c=9===a.length?a.substr(0,8):a.substr(0,9),e.charAt(parseInt(c,10)%23)===f)},"Please specify a valid NIE number."),a.validator.addMethod("nifES",function(a,b){"use strict";return!!this.optional(b)||(a=a.toUpperCase(),!!a.match("((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)")&&(/^[0-9]{8}[A-Z]{1}$/.test(a)?"TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.substring(8,0)%23)===a.charAt(8):!!/^[KLM]{1}/.test(a)&&a[8]==="TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.substring(8,1)%23)))},"Please specify a valid NIF number."),a.validator.addMethod("nipPL",function(a){"use strict";if(a=a.replace(/[^0-9]/g,""),10!==a.length)return!1;for(var b=[6,5,7,2,3,4,5,6,7],c=0,d=0;d<9;d++)c+=b[d]*a[d];var e=c%11,f=10===e?0:e;return f===parseInt(a[9],10)},"Please specify a valid NIP number."),a.validator.addMethod("notEqualTo",function(b,c,d){return this.optional(c)||!a.validator.methods.equalTo.call(this,b,c,d)},"Please enter a different value, values must not be the same."),a.validator.addMethod("nowhitespace",function(a,b){return this.optional(b)||/^\S+$/i.test(a)},"No white space please"),a.validator.addMethod("pattern",function(a,b,c){return!!this.optional(b)||("string"==typeof c&&(c=new RegExp("^(?:"+c+")$")),c.test(a))},"Invalid format."),a.validator.addMethod("phoneNL",function(a,b){return this.optional(b)||/^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)[1-9]((\s|\s?\-\s?)?[0-9]){8}$/.test(a)},"Please specify a valid phone number."),a.validator.addMethod("phonesUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)(?:1\d{8,9}|[23]\d{9}|7(?:[1345789]\d{8}|624\d{6})))$/)},"Please specify a valid uk phone number"),a.validator.addMethod("phoneUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?)|(?:\(?0))(?:\d{2}\)?\s?\d{4}\s?\d{4}|\d{3}\)?\s?\d{3}\s?\d{3,4}|\d{4}\)?\s?(?:\d{5}|\d{3}\s?\d{3})|\d{5}\)?\s?\d{4,5})$/)},"Please specify a valid phone number"),a.validator.addMethod("phoneUS",function(a,b){return a=a.replace(/\s+/g,""),this.optional(b)||a.length>9&&a.match(/^(\+?1-?)?(\([2-9]([02-9]\d|1[02-9])\)|[2-9]([02-9]\d|1[02-9]))-?[2-9]([02-9]\d|1[02-9])-?\d{4}$/)},"Please specify a valid phone number"),a.validator.addMethod("postalcodeBR",function(a,b){return this.optional(b)||/^\d{2}.\d{3}-\d{3}?$|^\d{5}-?\d{3}?$/.test(a)},"Informe um CEP válido."),a.validator.addMethod("postalCodeCA",function(a,b){return this.optional(b)||/^[ABCEGHJKLMNPRSTVXY]\d[ABCEGHJKLMNPRSTVWXYZ] *\d[ABCEGHJKLMNPRSTVWXYZ]\d$/i.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postalcodeIT",function(a,b){return this.optional(b)||/^\d{5}$/.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postalcodeNL",function(a,b){return this.optional(b)||/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postcodeUK",function(a,b){return this.optional(b)||/^((([A-PR-UWYZ][0-9])|([A-PR-UWYZ][0-9][0-9])|([A-PR-UWYZ][A-HK-Y][0-9])|([A-PR-UWYZ][A-HK-Y][0-9][0-9])|([A-PR-UWYZ][0-9][A-HJKSTUW])|([A-PR-UWYZ][A-HK-Y][0-9][ABEHMNPRVWXY]))\s?([0-9][ABD-HJLNP-UW-Z]{2})|(GIR)\s?(0AA))$/i.test(a)},"Please specify a valid UK postcode"),a.validator.addMethod("require_from_group",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_req_grp")?f.data("valid_req_grp"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length>=d[0];return f.data("valid_req_grp",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),h},a.validator.format("Please fill at least {0} of these fields.")),a.validator.addMethod("skip_or_fill_minimum",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_skip")?f.data("valid_skip"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length,i=0===h||h>=d[0];return f.data("valid_skip",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),i},a.validator.format("Please either skip these fields or fill at least {0} of them.")),a.validator.addMethod("stateUS",function(a,b,c){var d,e="undefined"==typeof c,f=!e&&"undefined"!=typeof c.caseSensitive&&c.caseSensitive,g=!e&&"undefined"!=typeof c.includeTerritories&&c.includeTerritories,h=!e&&"undefined"!=typeof c.includeMilitary&&c.includeMilitary;return d=g||h?g&&h?"^(A[AEKLPRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":g?"^(A[KLRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":"^(A[AEKLPRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$":"^(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$",d=f?new RegExp(d):new RegExp(d,"i"),this.optional(b)||d.test(a)},"Please specify a valid state"),a.validator.addMethod("strippedminlength",function(b,c,d){return a(b).text().length>=d},a.validator.format("Please enter at least {0} characters")),a.validator.addMethod("time",function(a,b){return this.optional(b)||/^([01]\d|2[0-3]|[0-9])(:[0-5]\d){1,2}$/.test(a)},"Please enter a valid time, between 00:00 and 23:59"),a.validator.addMethod("time12h",function(a,b){return this.optional(b)||/^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test(a)},"Please enter a valid time in 12-hour am/pm format"),a.validator.addMethod("url2",function(a,b){return this.optional(b)||/^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)*(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i.test(a)},a.validator.messages.url),a.validator.addMethod("vinUS",function(a){if(17!==a.length)return!1;var b,c,d,e,f,g,h=["A","B","C","D","E","F","G","H","J","K","L","M","N","P","R","S","T","U","V","W","X","Y","Z"],i=[1,2,3,4,5,6,7,8,1,2,3,4,5,7,9,2,3,4,5,6,7,8,9],j=[8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2],k=0;for(b=0;b<17;b++){if(e=j[b],d=a.slice(b,b+1),8===b&&(g=d),isNaN(d)){for(c=0;c?@\[\\\]^`{|}~])/g, "\\$1"); 39 | } 40 | 41 | function getModelPrefix(fieldName) { 42 | return fieldName.substr(0, fieldName.lastIndexOf(".") + 1); 43 | } 44 | 45 | function appendModelPrefix(value, prefix) { 46 | if (value.indexOf("*.") === 0) { 47 | value = value.replace("*.", prefix); 48 | } 49 | return value; 50 | } 51 | 52 | function onError(error, inputElement) { // 'this' is the form element 53 | var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"), 54 | replaceAttrValue = container.attr("data-valmsg-replace"), 55 | replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null; 56 | 57 | container.removeClass("field-validation-valid").addClass("field-validation-error"); 58 | error.data("unobtrusiveContainer", container); 59 | 60 | if (replace) { 61 | container.empty(); 62 | error.removeClass("input-validation-error").appendTo(container); 63 | } 64 | else { 65 | error.hide(); 66 | } 67 | } 68 | 69 | function onErrors(event, validator) { // 'this' is the form element 70 | var container = $(this).find("[data-valmsg-summary=true]"), 71 | list = container.find("ul"); 72 | 73 | if (list && list.length && validator.errorList.length) { 74 | list.empty(); 75 | container.addClass("validation-summary-errors").removeClass("validation-summary-valid"); 76 | 77 | $.each(validator.errorList, function () { 78 | $("
  • ").html(this.message).appendTo(list); 79 | }); 80 | } 81 | } 82 | 83 | function onSuccess(error) { // 'this' is the form element 84 | var container = error.data("unobtrusiveContainer"); 85 | 86 | if (container) { 87 | var replaceAttrValue = container.attr("data-valmsg-replace"), 88 | replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null; 89 | 90 | container.addClass("field-validation-valid").removeClass("field-validation-error"); 91 | error.removeData("unobtrusiveContainer"); 92 | 93 | if (replace) { 94 | container.empty(); 95 | } 96 | } 97 | } 98 | 99 | function onReset(event) { // 'this' is the form element 100 | var $form = $(this), 101 | key = '__jquery_unobtrusive_validation_form_reset'; 102 | if ($form.data(key)) { 103 | return; 104 | } 105 | // Set a flag that indicates we're currently resetting the form. 106 | $form.data(key, true); 107 | try { 108 | $form.data("validator").resetForm(); 109 | } finally { 110 | $form.removeData(key); 111 | } 112 | 113 | $form.find(".validation-summary-errors") 114 | .addClass("validation-summary-valid") 115 | .removeClass("validation-summary-errors"); 116 | $form.find(".field-validation-error") 117 | .addClass("field-validation-valid") 118 | .removeClass("field-validation-error") 119 | .removeData("unobtrusiveContainer") 120 | .find(">*") // If we were using valmsg-replace, get the underlying error 121 | .removeData("unobtrusiveContainer"); 122 | } 123 | 124 | function validationInfo(form) { 125 | var $form = $(form), 126 | result = $form.data(data_validation), 127 | onResetProxy = $.proxy(onReset, form), 128 | defaultOptions = $jQval.unobtrusive.options || {}, 129 | execInContext = function (name, args) { 130 | var func = defaultOptions[name]; 131 | func && $.isFunction(func) && func.apply(form, args); 132 | }; 133 | 134 | if (!result) { 135 | result = { 136 | options: { // options structure passed to jQuery Validate's validate() method 137 | errorClass: defaultOptions.errorClass || "input-validation-error", 138 | errorElement: defaultOptions.errorElement || "span", 139 | errorPlacement: function () { 140 | onError.apply(form, arguments); 141 | execInContext("errorPlacement", arguments); 142 | }, 143 | invalidHandler: function () { 144 | onErrors.apply(form, arguments); 145 | execInContext("invalidHandler", arguments); 146 | }, 147 | messages: {}, 148 | rules: {}, 149 | success: function () { 150 | onSuccess.apply(form, arguments); 151 | execInContext("success", arguments); 152 | } 153 | }, 154 | attachValidation: function () { 155 | $form 156 | .off("reset." + data_validation, onResetProxy) 157 | .on("reset." + data_validation, onResetProxy) 158 | .validate(this.options); 159 | }, 160 | validate: function () { // a validation function that is called by unobtrusive Ajax 161 | $form.validate(); 162 | return $form.valid(); 163 | } 164 | }; 165 | $form.data(data_validation, result); 166 | } 167 | 168 | return result; 169 | } 170 | 171 | $jQval.unobtrusive = { 172 | adapters: [], 173 | 174 | parseElement: function (element, skipAttach) { 175 | /// 176 | /// Parses a single HTML element for unobtrusive validation attributes. 177 | /// 178 | /// The HTML element to be parsed. 179 | /// [Optional] true to skip attaching the 180 | /// validation to the form. If parsing just this single element, you should specify true. 181 | /// If parsing several elements, you should specify false, and manually attach the validation 182 | /// to the form when you are finished. The default is false. 183 | var $element = $(element), 184 | form = $element.parents("form")[0], 185 | valInfo, rules, messages; 186 | 187 | if (!form) { // Cannot do client-side validation without a form 188 | return; 189 | } 190 | 191 | valInfo = validationInfo(form); 192 | valInfo.options.rules[element.name] = rules = {}; 193 | valInfo.options.messages[element.name] = messages = {}; 194 | 195 | $.each(this.adapters, function () { 196 | var prefix = "data-val-" + this.name, 197 | message = $element.attr(prefix), 198 | paramValues = {}; 199 | 200 | if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy) 201 | prefix += "-"; 202 | 203 | $.each(this.params, function () { 204 | paramValues[this] = $element.attr(prefix + this); 205 | }); 206 | 207 | this.adapt({ 208 | element: element, 209 | form: form, 210 | message: message, 211 | params: paramValues, 212 | rules: rules, 213 | messages: messages 214 | }); 215 | } 216 | }); 217 | 218 | $.extend(rules, { "__dummy__": true }); 219 | 220 | if (!skipAttach) { 221 | valInfo.attachValidation(); 222 | } 223 | }, 224 | 225 | parse: function (selector) { 226 | /// 227 | /// Parses all the HTML elements in the specified selector. It looks for input elements decorated 228 | /// with the [data-val=true] attribute value and enables validation according to the data-val-* 229 | /// attribute values. 230 | /// 231 | /// Any valid jQuery selector. 232 | 233 | // $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one 234 | // element with data-val=true 235 | var $selector = $(selector), 236 | $forms = $selector.parents() 237 | .addBack() 238 | .filter("form") 239 | .add($selector.find("form")) 240 | .has("[data-val=true]"); 241 | 242 | $selector.find("[data-val=true]").each(function () { 243 | $jQval.unobtrusive.parseElement(this, true); 244 | }); 245 | 246 | $forms.each(function () { 247 | var info = validationInfo(this); 248 | if (info) { 249 | info.attachValidation(); 250 | } 251 | }); 252 | } 253 | }; 254 | 255 | adapters = $jQval.unobtrusive.adapters; 256 | 257 | adapters.add = function (adapterName, params, fn) { 258 | /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation. 259 | /// The name of the adapter to be added. This matches the name used 260 | /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). 261 | /// [Optional] An array of parameter names (strings) that will 262 | /// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and 263 | /// mmmm is the parameter name). 264 | /// The function to call, which adapts the values from the HTML 265 | /// attributes into jQuery Validate rules and/or messages. 266 | /// 267 | if (!fn) { // Called with no params, just a function 268 | fn = params; 269 | params = []; 270 | } 271 | this.push({ name: adapterName, params: params, adapt: fn }); 272 | return this; 273 | }; 274 | 275 | adapters.addBool = function (adapterName, ruleName) { 276 | /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where 277 | /// the jQuery Validate validation rule has no parameter values. 278 | /// The name of the adapter to be added. This matches the name used 279 | /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). 280 | /// [Optional] The name of the jQuery Validate rule. If not provided, the value 281 | /// of adapterName will be used instead. 282 | /// 283 | return this.add(adapterName, function (options) { 284 | setValidationValues(options, ruleName || adapterName, true); 285 | }); 286 | }; 287 | 288 | adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) { 289 | /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where 290 | /// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and 291 | /// one for min-and-max). The HTML parameters are expected to be named -min and -max. 292 | /// The name of the adapter to be added. This matches the name used 293 | /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name). 294 | /// The name of the jQuery Validate rule to be used when you only 295 | /// have a minimum value. 296 | /// The name of the jQuery Validate rule to be used when you only 297 | /// have a maximum value. 298 | /// The name of the jQuery Validate rule to be used when you 299 | /// have both a minimum and maximum value. 300 | /// [Optional] The name of the HTML attribute that 301 | /// contains the minimum value. The default is "min". 302 | /// [Optional] The name of the HTML attribute that 303 | /// contains the maximum value. The default is "max". 304 | /// 305 | return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) { 306 | var min = options.params.min, 307 | max = options.params.max; 308 | 309 | if (min && max) { 310 | setValidationValues(options, minMaxRuleName, [min, max]); 311 | } 312 | else if (min) { 313 | setValidationValues(options, minRuleName, min); 314 | } 315 | else if (max) { 316 | setValidationValues(options, maxRuleName, max); 317 | } 318 | }); 319 | }; 320 | 321 | adapters.addSingleVal = function (adapterName, attribute, ruleName) { 322 | /// Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where 323 | /// the jQuery Validate validation rule has a single value. 324 | /// The name of the adapter to be added. This matches the name used 325 | /// in the data-val-nnnn HTML attribute(where nnnn is the adapter name). 326 | /// [Optional] The name of the HTML attribute that contains the value. 327 | /// The default is "val". 328 | /// [Optional] The name of the jQuery Validate rule. If not provided, the value 329 | /// of adapterName will be used instead. 330 | /// 331 | return this.add(adapterName, [attribute || "val"], function (options) { 332 | setValidationValues(options, ruleName || adapterName, options.params[attribute]); 333 | }); 334 | }; 335 | 336 | $jQval.addMethod("__dummy__", function (value, element, params) { 337 | return true; 338 | }); 339 | 340 | $jQval.addMethod("regex", function (value, element, params) { 341 | var match; 342 | if (this.optional(element)) { 343 | return true; 344 | } 345 | 346 | match = new RegExp(params).exec(value); 347 | return (match && (match.index === 0) && (match[0].length === value.length)); 348 | }); 349 | 350 | $jQval.addMethod("nonalphamin", function (value, element, nonalphamin) { 351 | var match; 352 | if (nonalphamin) { 353 | match = value.match(/\W/g); 354 | match = match && match.length >= nonalphamin; 355 | } 356 | return match; 357 | }); 358 | 359 | if ($jQval.methods.extension) { 360 | adapters.addSingleVal("accept", "mimtype"); 361 | adapters.addSingleVal("extension", "extension"); 362 | } else { 363 | // for backward compatibility, when the 'extension' validation method does not exist, such as with versions 364 | // of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for 365 | // validating the extension, and ignore mime-type validations as they are not supported. 366 | adapters.addSingleVal("extension", "extension", "accept"); 367 | } 368 | 369 | adapters.addSingleVal("regex", "pattern"); 370 | adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"); 371 | adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range"); 372 | adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength"); 373 | adapters.add("equalto", ["other"], function (options) { 374 | var prefix = getModelPrefix(options.element.name), 375 | other = options.params.other, 376 | fullOtherName = appendModelPrefix(other, prefix), 377 | element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0]; 378 | 379 | setValidationValues(options, "equalTo", element); 380 | }); 381 | adapters.add("required", function (options) { 382 | // jQuery Validate equates "required" with "mandatory" for checkbox elements 383 | if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") { 384 | setValidationValues(options, "required", true); 385 | } 386 | }); 387 | adapters.add("remote", ["url", "type", "additionalfields"], function (options) { 388 | var value = { 389 | url: options.params.url, 390 | type: options.params.type || "GET", 391 | data: {} 392 | }, 393 | prefix = getModelPrefix(options.element.name); 394 | 395 | $.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) { 396 | var paramName = appendModelPrefix(fieldName, prefix); 397 | value.data[paramName] = function () { 398 | var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']"); 399 | // For checkboxes and radio buttons, only pick up values from checked fields. 400 | if (field.is(":checkbox")) { 401 | return field.filter(":checked").val() || field.filter(":hidden").val() || ''; 402 | } 403 | else if (field.is(":radio")) { 404 | return field.filter(":checked").val() || ''; 405 | } 406 | return field.val(); 407 | }; 408 | }); 409 | 410 | setValidationValues(options, "remote", value); 411 | }); 412 | adapters.add("password", ["min", "nonalphamin", "regex"], function (options) { 413 | if (options.params.min) { 414 | setValidationValues(options, "minlength", options.params.min); 415 | } 416 | if (options.params.nonalphamin) { 417 | setValidationValues(options, "nonalphamin", options.params.nonalphamin); 418 | } 419 | if (options.params.regex) { 420 | setValidationValues(options, "regex", options.params.regex); 421 | } 422 | }); 423 | adapters.add("fileextensions", ["extensions"], function (options) { 424 | setValidationValues(options, "extension", options.params.extensions); 425 | }); 426 | 427 | $(function () { 428 | $jQval.unobtrusive.parse(document); 429 | }); 430 | 431 | return $jQval.unobtrusive; 432 | })); 433 | --------------------------------------------------------------------------------