Skip to content

Commit

Permalink
Added support for lifecycle event notifications (#294)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonjoh authored Nov 29, 2023
1 parent ee883b0 commit b3ccab1
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 13 deletions.
2 changes: 2 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
121 changes: 121 additions & 0 deletions src/GraphWebhooks/Controllers/LifecycleController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Implements the lifecycle notification endpoint which receives
/// notifications from Microsoft Graph
/// </summary>
public class LifecycleController : Controller
{
private readonly GraphServiceClient _graphClient;
private readonly SubscriptionStore _subscriptionStore;
private readonly ILogger<LifecycleController> _logger;

public LifecycleController(
GraphServiceClient graphClient,
SubscriptionStore subscriptionStore,
ILogger<LifecycleController> logger)
{
_graphClient = graphClient ?? throw new ArgumentException(nameof(graphClient));
_subscriptionStore = subscriptionStore ?? throw new ArgumentException(nameof(subscriptionStore));
_logger = logger ?? throw new ArgumentException(nameof(logger));
}

/// <summary>
/// POST /lifecycle
/// </summary>
/// <param name="validationToken">Optional. Validation token sent by Microsoft Graph during endpoint validation phase</param>
/// <returns>IActionResult</returns>
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> 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<ChangeNotificationCollection>(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");
}
}
}
}
8 changes: 2 additions & 6 deletions src/GraphWebhooks/Controllers/ListenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -70,16 +69,13 @@ public async Task<IActionResult> 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<ChangeNotificationCollection>(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);
Expand Down
14 changes: 7 additions & 7 deletions src/GraphWebhooks/Controllers/WatchController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public async Task<IActionResult> Delegated()
{
ChangeType = "created",
NotificationUrl = $"{_notificationHost}/listen",
LifecycleNotificationUrl = $"{_notificationHost}/lifecycle",
Resource = "me/mailfolders/inbox/messages",
ClientState = Guid.NewGuid().ToString(),
IncludeResourceData = false,
Expand Down Expand Up @@ -145,6 +146,7 @@ public async Task<IActionResult> AppOnly()
{
ChangeType = "created",
NotificationUrl = $"{_notificationHost}/listen",
LifecycleNotificationUrl = $"{_notificationHost}/lifecycle",
Resource = "/teams/getAllMessages",
ClientState = Guid.NewGuid().ToString(),
IncludeResourceData = true,
Expand Down Expand Up @@ -219,21 +221,19 @@ 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)
{
// Throw MicrosoftIdentityWebChallengeUserException to allow
// 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" });
}

/// <summary>
Expand Down

0 comments on commit b3ccab1

Please sign in to comment.