How to use Remote Event Receivers with .NET Core (or .NET 5) and PnP.Framework

In June 2020, a .NET Standard version of SharePoint CSOM was released. It means that now we can build projects for SharePoint, that target multiple platforms. At the beginning of 2021, a .NET Standard version of PnP-Sites-Core was also released (with a brand new name PnP.Framework and an updated codebase). However, there are some limitations in the .NET Standard version of SharePoint CSOM. Especially the lack of Remote Event Receivers (RER). The whole namespace was dropped. In some cases, you cannot replace RER with Webhooks without loss of functionality. Sometimes you just need RER or you're upgrading your code and cannot migrate RERs to webhook, since that's expensive. 

So, let's bring support of Remote Event Receivers back to .NET Core \ .NET 5+.

The source code sample for this blog post is available at GitHub here (the master branch contains code for .NET Core, if you're looking .NET 5 sample, use net5 branch).

The goal

We want to use a modern stack, thus we need

  • Azure Function (or just a regular ASP.NET Core web app, it doesn't really matter) to host our RER. 
  • PnP.Framework, SharePoint CSOM all based on .NET Standard
  • the app targets .NET Core (.NET 5)

The function should work very similar to a regular RER code - process different types of events (sync -ing, ItemAdding, async -ed, ItemAdded, etc)The function should also create a ClientContext based on the event properties.

Some important notes

How is it possible? Well, simply because that RER is just an HTTP endpoint, we can use Azure Function as such an endpoint and then process HTTP request payload manually. A few years ago I wrote about this concept here

If RER was configured correctly, in the request payload you receive a SharePoint context token (generated by Azure ACS). In "old" SharePoint helper libraries, there was a method to create a ClientContext from event properties called TokenHelper.CreateRemoteEventReceiverClientContext. Under the hood, it validates that your context token is good (signing key, signature, audience, issuer) and then generates an access token out of it and finally creates ClientContext object. We cannot use TokenHelper here, because it relies on System.Web library, which is not available in .NET Standard. 

In our code, we should do exactly the same - validate the context token and then create a ClientContext object out of it. The validation step is extremely important, otherwise, your RER endpoint will be totally unprotected and available anonymously. Anybody with SharePoint knowledge (like me, ha-ha!) will be able to construct an HTTP payload correctly and send it to your service, potentially corrupting data. 

Add Remote Event Receiver 

IMPORTANT. If you're adding RER, you should always use SharePoint app permissions (aka ClientId and ClienteSecret authentication using SharePoint app registration). Only in that case you will receive a valid context token in your RER and will be able to validate it. Failing that a context token will be null. 

The sample code to add an RER to the "Lists/TestList" is available under PnP.Framework.RER.Console project. If you want to test your RER locally, then you should use ngrok or a similar service. In that case, you should register a new SharePoint app (using AppRegNew.aspx) and specify the ngrok domain as a valid app domain. Otherwise, you will receive an endpoint mismatch error in your RER. Don't forget to provide permissions to your app registration using AppInv.aspx (you can follow these steps from the wiki to register a new app in SharePoint with AppRegNew.aspx and then adding permissions to either a tenant or site collection). 

To run the ngrok service execute the below command:

ngrok http 7071 --host-header=localhost

where 7071 is a default port for locally running azure function. 

To make PnP.Framework.RER.Console work, you should also provide the below configuration using either appsettings.json or user secrets

{
  "ClientId": "<client id>",
  "ClientSecret": "<client secret>",
  "SiteUrl":  "<target site url where to add RER>"
}

Implement Azure Function

We need just a regular HTTP-triggered Azure Function. In the Azure function body, we should parse payload, validate context token, then create an access token and process the event. 

Below is the sample payload you receive in your function: 

spoiler (click to show)
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
	<s:Body>
		<ProcessOneWayEvent xmlns="http://schemas.microsoft.com/sharepoint/remoteapp/">
			<properties xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
				<AppEventProperties i:nil="true"/>
				<ContextToken>token</ContextToken>
				<CorrelationId>3181b49f-f017-2000-9e65-a10548a65aaa</CorrelationId>
				<CultureLCID>1033</CultureLCID>
				<EntityInstanceEventProperties i:nil="true"/>
				<ErrorCode/>
				<ErrorMessage/>
				<EventType>ItemAdded</EventType>
				<ItemEventProperties>
					<AfterProperties xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
						<a:KeyValueOfstringanyType>
							<a:Key>TimesInUTC</a:Key>
							<a:Value i:type="b:string" xmlns:b="http://www.w3.org/2001/XMLSchema">TRUE</a:Value>
						</a:KeyValueOfstringanyType>
						<a:KeyValueOfstringanyType>
							<a:Key>ContentType</a:Key>
							<a:Value i:type="b:string" xmlns:b="http://www.w3.org/2001/XMLSchema">Item</a:Value>
						</a:KeyValueOfstringanyType>
						<a:KeyValueOfstringanyType>
							<a:Key>Title</a:Key>
							<a:Value i:type="b:string" xmlns:b="http://www.w3.org/2001/XMLSchema">test</a:Value>
						</a:KeyValueOfstringanyType>
						<a:KeyValueOfstringanyType>
							<a:Key>FileSystemObjectType</a:Key>
							<a:Value i:type="b:string" xmlns:b="http://www.w3.org/2001/XMLSchema">File</a:Value>
						</a:KeyValueOfstringanyType>
					</AfterProperties>
					<AfterUrl i:nil="true"/>
					<BeforeProperties xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays"/>
					<BeforeUrl/>
					<CurrentUserId>6</CurrentUserId>
					<ExternalNotificationMessage i:nil="true"/>
					<IsBackgroundSave>false</IsBackgroundSave>
					<ListId>0cc0003f-5017-4f86-ad4d-5ec34d6e5481</ListId>
					<ListItemId>53</ListItemId>
					<ListTitle>TestList</ListTitle>
					<UserDisplayName>Sergei Sergeev</UserDisplayName>
					<UserLoginName>i:0#.f|membership|user@domain.com</UserLoginName>
					<Versionless>false</Versionless>
					<WebUrl>https://mastaq.sharepoint.com/sites/dev</WebUrl>
				</ItemEventProperties>
				<ListEventProperties i:nil="true"/>
				<SecurityEventProperties i:nil="true"/>
				<UICultureLCID>1033</UICultureLCID>
				<WebEventProperties i:nil="true"/>
			</properties>
		</ProcessOneWayEvent>
	</s:Body>
</s:Envelope>

For Azure Function we also need SharePoint credentials. You can either use local.settings.json or better user secrets to store credentials:

{
  "SharePointApp": {
    "ClientId": "<client>",
    "ClientSecret": "<secret>"
  }
}

Payload processing

Firstly, parse the whole payload as XML:

var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var xdoc = XDocument.Parse(requestBody);

Then deserialize it:

var eventProperties = SerializerHelper.Deserialize<SPRemoteEventProperties>(payload);

For deserialization I use DataContractSerializer. SPRemoteEventProperties is the same class as we used in RERs before. However, the namespace Microsoft.SharePoint.Client.EventReceivers is not available when you target .NET Standard, thus I simply copied all classes using old good Reflector (ILSpy). Namespace PnP.Framework.RER.Common.EventReceivers in the sample contains all those classes extracted from SharePoint.CSOM library. Since that's just a POCO classes with data contract attributes, it's absolutely safe to use. 

Token validation

As said, token validation is an important step. By using token validation we are sure, that we received a token from the right tenant, generated by trusted Azure ACS, it's not expired. We can think about it as authentication for our RER. If a token is not valid, then authentication will fail. 

Sample code below validates context token, extracted from RER payload:

private void ValidateToken()
{
	var key = new SymmetricSecurityKey(Convert.FromBase64String(_sharePointAppCreds.ClientSecret));
	var handler = new JwtSecurityTokenHandler();
	SecurityToken validatedToken;
	var token = handler.ReadJwtToken(_contextToken);

	var audienceValue = token.Claims.Single(c => c.Type == "aud").Value;
	var tenantId = audienceValue.Substring(audienceValue.IndexOf('@') + 1);

	handler.ValidateToken(_contextToken, new TokenValidationParameters
	{
		IssuerSigningKey = key,
		ValidateAudience = true,
		ValidAudience = $"{_sharePointAppCreds.ClientId}/{_host}@{tenantId}",
		ValidateIssuer = true,
		ValidIssuer = $"{AcsPrincipalName}@{tenantId}",
		ValidateLifetime = true,
		ValidateIssuerSigningKey = true
	}, out validatedToken);

	_validated = true;
	_parsedToken = validatedToken as JwtSecurityToken;
}

For validation we need ClientId, ClientSecret, AcsPrincipalName (a constant, "00000001-0000-0000-c000-000000000000"), tenant id (extracted from token) and RER host (extracted from headers). If you build a single-tenant app, you can hardcode tenant id (also called realm), in multitenant you have to parse it from context token. In the above code, we validate the token signature with ClientSecret, token lifetime, issuer, and audience. 

Access token generation

Most of the code for a token generation was grabbed from TokenHelper.cs class and adopted for .NET Core. The process contains two steps: 

  1. Obtaining security token service URL for the current tenant. You need to send HTTP GET to https://accounts.accesscontrol.windows.net/metadata/json/1?realm=<tenantId> (the endpoint is anonymously available) and extract the location for OAuth2 protocol
  2. Then HTTP POST to the sts URL using OAuth refresh flow. 

Below code sample requests an access token from refresh token (refresh token was extracted from context token which we received in the request payload before):

private async Task<AccessTokenResponse> GetAccessTokenAsync(string stsUrl, string clientId, string resource, string refreshToken)
{
	var formContent = new FormUrlEncodedContent(new Dictionary<string, string> {
		{ "grant_type", "refresh_token" },
		{ "client_id", clientId },
		{ "client_secret", _sharePointAppCreds.ClientSecret },
		{ "refresh_token", refreshToken },
		{ "resource", resource },
	});

	var client = new HttpClient();
	var result = await client.PostAsync(stsUrl, formContent);
	var content = await result.Content.ReadAsStringAsync();
	return JsonConvert.DeserializeObject<AccessTokenResponse>(content);
}

As said, I haven't invented the wheel here, I just adopted a code from TokeHelper.cs

As soon as we have an access token, we are good to go! You can contract a client context with PnP.Framework and start using SharePoint API:

public async Task<ClientContext> GetUserClientContextAsync(string siteUrl)
{
	var data = await GetAccessTokenAsync(siteUrl);
	var accessToken = new SecureString();
	Array.ForEach(data.access_token.ToArray(), accessToken.AppendChar);

	var authManager = new AuthenticationManager(accessToken);

	return await authManager.GetContextAsync(siteUrl);
}

Important observations here. The access token we obtain here is a user access token (delegated permissions). All operations in SharePoint will be done with current user permission (the user, who triggered the RER). If you need kind of "elevated permissions" you should use an app-only access token:

public ClientContext GetAppClientContextAsync(string siteUrl)
{
	if (!_validated)
	{
		ValidateToken();
	}

	var authManager = new AuthenticationManager();
	return authManager.GetACSAppOnlyContext(siteUrl, _sharePointAppCreds.ClientId, _sharePointAppCreds.ClientSecret);
}

Just don't forget to validate the context token even if you use app-only permissions. 

Event processing

There are two types of events available in SharePoint  - synchronous (-ing ItemAdding, etc.) and asynchronous (-ed, ItemAdded, etc.). Depending on the event type you have a different node in the XML RER payload. Simply parse it and process the right path:

if (eventRoot.Name.LocalName == "ProcessEvent")
{
	return await ProcessSyncEvent(eventProperties, context);
}

if (eventRoot.Name.LocalName == "ProcessOneWayEvent")
{
	return await ProcessAsyncEvent(eventProperties, context);
}

Then just process the event:

private async Task<IActionResult> ProcessSyncEvent(SPRemoteEventProperties properties, ClientContext context)
{
	switch (properties.EventType)
	{
		case SPRemoteEventType.ItemAdding:
			{
				// do things
				break;
			}
		//etc
		default: { break; }
	}
	var result = new SPRemoteEventResult
	{
		Status = SPRemoteEventServiceStatus.Continue
	};

	return new ContentResult
	{
		Content = CreateEventResponse(result),
		ContentType = "text/xml",
		StatusCode = (int?)HttpStatusCode.OK
	};
}

Sync events can be canceled, in this case, you should return an error status like this:

var result = new SPRemoteEventResult
{
	Status = SPRemoteEventServiceStatus.CancelWithError,
	ErrorMessage = message
};

return new ContentResult
{
	Content = CreateEventResponse(result),
	ContentType = "text/xml",
	StatusCode = (int?)HttpStatusCode.InternalServerError
};

Basically, this is it. The source code you can find here. Please use comments or GitHub issues in case of any questions. 

Title image attribution - Tree photo created by wirestock - www.freepik.com