From b3ccab1f7faef2808495d01ab7fbef39600f24c0 Mon Sep 17 00:00:00 2001 From: Jason Johnston Date: Wed, 29 Nov 2023 16:47:02 -0500 Subject: [PATCH] Added support for lifecycle event notifications (#294) --- .vscode/launch.json | 2 + .../Controllers/LifecycleController.cs | 121 ++++++++++++++++++ .../Controllers/ListenController.cs | 8 +- .../Controllers/WatchController.cs | 14 +- 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 src/GraphWebhooks/Controllers/LifecycleController.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index cd50f88..4d04b62 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,8 @@ "program": "${workspaceFolder}/src/GraphWebhooks/bin/Debug/net8.0/GraphWebhooks.dll", "args": [], "cwd": "${workspaceFolder}/src/GraphWebhooks", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", "stopAtEntry": false, // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser "serverReadyAction": { diff --git a/src/GraphWebhooks/Controllers/LifecycleController.cs b/src/GraphWebhooks/Controllers/LifecycleController.cs new file mode 100644 index 0000000..facdffd --- /dev/null +++ b/src/GraphWebhooks/Controllers/LifecycleController.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using GraphWebhooks.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.Identity.Web; +using Microsoft.Kiota.Abstractions.Serialization; + +namespace GraphWebhooks.Controllers; + +/// +/// Implements the lifecycle notification endpoint which receives +/// notifications from Microsoft Graph +/// +public class LifecycleController : Controller +{ + private readonly GraphServiceClient _graphClient; + private readonly SubscriptionStore _subscriptionStore; + private readonly ILogger _logger; + + public LifecycleController( + GraphServiceClient graphClient, + SubscriptionStore subscriptionStore, + ILogger logger) + { + _graphClient = graphClient ?? throw new ArgumentException(nameof(graphClient)); + _subscriptionStore = subscriptionStore ?? throw new ArgumentException(nameof(subscriptionStore)); + _logger = logger ?? throw new ArgumentException(nameof(logger)); + } + + /// + /// POST /lifecycle + /// + /// Optional. Validation token sent by Microsoft Graph during endpoint validation phase + /// IActionResult + [HttpPost] + [AllowAnonymous] + public async Task Index([FromQuery] string? validationToken = null) + { + // If there is a validation token in the query string, + // send it back in a 200 OK text/plain response + if (!string.IsNullOrEmpty(validationToken)) + { + return Ok(validationToken); + } + + // Use the Graph client's serializer to deserialize the body + using var bodyStream = new MemoryStream(); + await Request.Body.CopyToAsync(bodyStream); + bodyStream.Seek(0, SeekOrigin.Begin); + var notifications = KiotaJsonSerializer.Deserialize(bodyStream); + + if (notifications == null || notifications.Value == null) return Accepted(); + + // Process any lifecycle events + var lifecycleNotifications = notifications.Value.Where(n => n.LifecycleEvent != null); + foreach (var lifecycleNotification in lifecycleNotifications) + { + _logger.LogInformation("Received {eventType} notification for subscription {subscriptionId}", + lifecycleNotification.LifecycleEvent.ToString(), lifecycleNotification.SubscriptionId); + + if (lifecycleNotification.LifecycleEvent == LifecycleEventType.ReauthorizationRequired) + { + // The subscription needs to be renewed + try + { + await RenewSubscriptionAsync(lifecycleNotification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renewing subscription"); + } + } + } + + // Return 202 to Graph to confirm receipt of notification. + // Not sending this will cause Graph to retry the notification. + return Accepted(); + } + + private async Task RenewSubscriptionAsync(ChangeNotification lifecycleNotification) + { + var subscriptionId = lifecycleNotification.SubscriptionId?.ToString(); + + if (!string.IsNullOrEmpty(subscriptionId)) + { + var subscription = _subscriptionStore.GetSubscriptionRecord(subscriptionId); + if (subscription != null && + !string.IsNullOrEmpty(subscription.UserId) && + !string.IsNullOrEmpty(subscription.TenantId)) + { + var isAppOnly = subscription.UserId.Equals("APP-ONLY", StringComparison.OrdinalIgnoreCase); + if (!isAppOnly) + { + // Since the POST comes from Graph, there's no user in the context + // Set the user to the user that owns the message. This will enable + // Microsoft.Identity.Web to acquire the proper token for the proper user + HttpContext.User = ClaimsPrincipalFactory + .FromTenantIdAndObjectId(subscription.TenantId, subscription.UserId); + HttpContext.User.AddMsalInfo(subscription.UserId, subscription.TenantId); + } + + var update = new Subscription + { + ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1), + }; + + await _graphClient.Subscriptions[subscriptionId] + .PatchAsync(update, req => + { + req.Options.WithAppOnly(isAppOnly); + }); + + _logger.LogInformation("Renewed subscription"); + } + } + } +} diff --git a/src/GraphWebhooks/Controllers/ListenController.cs b/src/GraphWebhooks/Controllers/ListenController.cs index 90f2b9d..f8ff43f 100644 --- a/src/GraphWebhooks/Controllers/ListenController.cs +++ b/src/GraphWebhooks/Controllers/ListenController.cs @@ -11,7 +11,6 @@ using Microsoft.Graph.Models; using Microsoft.Identity.Web; using Microsoft.Kiota.Abstractions.Serialization; -using Microsoft.Kiota.Serialization.Json; namespace GraphWebhooks.Controllers; @@ -70,16 +69,13 @@ public async Task Index([FromQuery] string? validationToken = nul return Ok(validationToken); } - // Read the body - using var reader = new StreamReader(Request.Body); - // Use the Graph client's serializer to deserialize the body - var bodyStream = new MemoryStream(); + using var bodyStream = new MemoryStream(); await Request.Body.CopyToAsync(bodyStream); bodyStream.Seek(0, SeekOrigin.Begin); var notifications = KiotaJsonSerializer.Deserialize(bodyStream); - if (notifications == null || notifications.Value == null) return Ok(); + if (notifications == null || notifications.Value == null) return Accepted(); // Validate any tokens in the payload var areTokensValid = await notifications.AreTokensValid(_tenantIds, _appIds); diff --git a/src/GraphWebhooks/Controllers/WatchController.cs b/src/GraphWebhooks/Controllers/WatchController.cs index faeb428..7048d8f 100644 --- a/src/GraphWebhooks/Controllers/WatchController.cs +++ b/src/GraphWebhooks/Controllers/WatchController.cs @@ -80,6 +80,7 @@ public async Task Delegated() { ChangeType = "created", NotificationUrl = $"{_notificationHost}/listen", + LifecycleNotificationUrl = $"{_notificationHost}/lifecycle", Resource = "me/mailfolders/inbox/messages", ClientState = Guid.NewGuid().ToString(), IncludeResourceData = false, @@ -145,6 +146,7 @@ public async Task AppOnly() { ChangeType = "created", NotificationUrl = $"{_notificationHost}/listen", + LifecycleNotificationUrl = $"{_notificationHost}/lifecycle", Resource = "/teams/getAllMessages", ClientState = Guid.NewGuid().ToString(), IncludeResourceData = true, @@ -219,9 +221,6 @@ await _graphClient.Subscriptions[subscriptionId] // Remove the subscription from the subscription store _subscriptionStore.DeleteSubscriptionRecord(subscriptionId); } - - // Redirect to Microsoft.Identity.Web's signout page - return RedirectToAction("SignOut", "Account", new { area = "MicrosoftIdentity" }); } catch (Exception ex) { @@ -229,11 +228,12 @@ await _graphClient.Subscriptions[subscriptionId] // Microsoft.Identity.Web to challenge the user for re-auth or consent if (ex.InnerException is MicrosoftIdentityWebChallengeUserException) throw; - // Otherwise display the error - return RedirectToAction("Index", "Home") - .WithError($"Error deleting subscription: {ex.Message}", - ex.ToString()); + // Otherwise log the error + _logger.LogError(ex, "Error deleting subscription"); } + + // Redirect to Microsoft.Identity.Web's signout page + return RedirectToAction("SignOut", "Account", new { area = "MicrosoftIdentity" }); } ///