Jul 21, 2020

Use the CSOM Library in your ASP.NET Core apps

Hi SharePoint devs!

Recently the support for .NET Standard in the CSOM library was released. It opens up a variety of new possibilities for SharePoint developers.

Let's create together an ASP.NET Core MVC web application, and let's use all the goodness of this framework while using our skills with the SharePoint Client Side Object Model library. It used not to be compatible with .NET Core applications, now it is .NET Standard compatible. It means the DLL can be used in any .NET Core project and all the platforms that uses it: non Windows OSes, Azure Function v3, Xamarin, ...

ASP.NET Core MVC

If you are like me a SharePoint developer, you probably have a strong background in .NET and C#, and you might probably have not had many opportunities of working with the latest .NET web technologies such as ASP.NET Core. I personnally had a couple projects opportunities using it, but I mainly learned it on my spare time. And really I love it!

Dependecy Injection For The Win !

I am a big fan of dependency injection, separation of concerns, and clean code. I hate when my code is polluted with much noise that is necessary to make the code work, but not strictly related to the functional purpose of the code I am currently writing. In such cases, I will tend to refactor the code to be clean and a pleasure to read. In other words, I don't want my code to need a comment at each line to be understood. I digress a bit, but my point is, with dependency injection, I can easily create the units of code, the building blocks, responsible for their own goal, and then I can simply say: "Hey, I need that block, give it to me!". I don't need to start instantiating a new service, need all the required configurations and so on it needs to work.

CSOM & .NET Core

The CSOM library that you are probably familiar with help us work against SharePoint remotely manipulating an object model. It is the remote counterpart of the SharePoint Server Object Model that is the historical main SharePoint API. That Server-Side API is only supported on SharePoint On-Prem since the code can only run on a SharePoint server. It is definitely no longer recommended since the Cloud-First strategy of Microsoft nowadays. On SharePoint Online, we have 3 means to programmatically work on the platform:

  1. CSOM: a .NET Library, it is the more complete API surface, you can basically do anything that is programmatically doable in SharePoint with this library. However it is limited to .NET.

  2. SharePoint REST API: A RESTful API that allows to programmatically work against SharePoint from any technology that has HTTP capabilities. It can be used in C#, JavaScript, Java, Python, Go, or any other language. It has however still some gaps and inconsitencies. Not everything that is doable with CSOM is doable with the REST API (e.g. Create a Content Type with a specific ID, ...). Moreover, it is not always very consistent and aligned with the principles of a real RESTful API.

  3. Microsoft Graph: It is the most recent API that allows to work against some parts of SharePoint, it is much more aligned on the RESTful conventions and is THE one API to use across the Microsoft 365 suite. It is huge and targets much much more than just SharePoint. However, unfortunately, so far, it only covers a very small part of the SharePoint capabilities. Anyway, it is where the major investments from Microsoft are (between these 3). The new SharePoint API capabilities will be in Microsoft Graph!

All that said, you probably understood that, for now, the most easy to use AND complete API is still CSOM. It has been there since SharePoint 2013 and has evolved a lot.

Unfortunately, CSOM used not to be compatible with .NET Core that is the future of .NET and has been around for several years now. But, today, after a veeery long time and high demand from the developers community, it finally is!

Let's create an App

Let me show you how we can create a .NET Core MVC app that will use CSOM in the context of the current logged in user.

Prerequisites

To follow this post, you will need the following things:

  • Access rights to your Azure AD portal
  • Have .NET Core v3.1 installed on your machine
  • Have Visual Studio Code (or your preferred code editor, but I will use VS Code and the dotnet CLI)

Let's register an Azure AD App

In order to authenticate our user, we will need to register a new application in our tenant. Sign in to the Azure AD Portal and go to Azure Active Directory > App Registrations. Click the New registration button

From the Register an application blade, select a name for your application. In the Redirect URI section, put the value https://localhost:5001

Register new app

Click the Register button.

From the Overview tab, copy the Client Id and Tenant Id

IDs

From the Authentication tab, add another Redirect URI: https://localhost:5001/signin-oidc

Redirect URI signin-oidc

Also make sure the ID Tokens option is checked in the Implicit Grant section.

From the Certificates & Secrets tab, create a new client secret and copy it in a safe location

From the API Permissions tab, click the Add a permission button and select the SharePoint API

SPO-API

Select the Delegated permission AllSites.FullControl

AllSites.FullControl

NOTE:

For the sake of simplicity in this example, I select the highest possible permission. In Production scenario, it is recommended to use the least necessary privilege to avoid troubles :)

Since we are using here a high privilege permission, it is required to be consented by an adminstrator, you can do it easily on the current blade clicking the button Grant admin consent for <tenant>

Let's create a new MVC App

From a console, type the following command

dotnet new mvc -o MVC_CSOM
cd MVC_CSOM
code .

(Keep that console open, we will reuse it later).

We have now a new scaffolded project open in code

New MVC Project

Let's install the packages we will need, open the MVC_CSOM.csproj file and make it look like :

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Identity.Web" Version="0.2.0-preview" />
    <PackageReference Include="Microsoft.Identity.Web.UI" Version="0.2.0-preview" />
    <PackageReference Include="Microsoft.SharePointOnline.CSOM" Version="16.1.20211.12000" />
  </ItemGroup>

</Project>

From the console, type

dotnet restore

In the appsettings.json file, put the following content and replace the placeholders accordingly with the values we got earlier:

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "{tenant}.onmicrosoft.com",
    "TenantId": "{tenant-id}",
    "ClientId": "{client-id}",
    "ClientSecret":"{client-secret}",
    "CallbackPath": "/signin-oidc"
  },
  "SharePoint": {
    "Url": "https://{tenant}.sharepoint.com/sites/{site}"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Let's create a service to get our CSOM ClientContext

Create a folder /Services and add a file SharePointClientContextService.cs into it with the following content:

using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Web;
using Microsoft.SharePoint.Client;

namespace MVC_CSOM.Services
{
    public static class SharePointClientContextFactoryServiceConfiguration
    {
        public static IServiceCollection AddSharePointContextFactory(this IServiceCollection serviceCollection)
        {
            serviceCollection.AddScoped<ISharePointClientContextFactory, SharePointClientContextFactory>();
            return serviceCollection;
        }

        public static IServiceCollection AddCurrentUserSharePointClientContext(this IServiceCollection serviceCollection)
        {
            serviceCollection.AddSharePointContextFactory();

            serviceCollection.AddScoped<ClientContext>((services) =>
            {
                var clientContextFactory = services.GetService<ISharePointClientContextFactory>();
                return clientContextFactory.GetClientContext();
            });

            return serviceCollection;
        }
    }

    public interface ISharePointClientContextFactory
    {
        ClientContext GetClientContext(string siteUrl = null);
    }

    class SharePointClientContextFactory : ISharePointClientContextFactory
    {
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly IConfiguration _configuration;

        public SharePointClientContextFactory(IConfiguration configuration, ITokenAcquisition tokenAcquisition)
        {
            _configuration = configuration;
            _tokenAcquisition = tokenAcquisition;
        }

        private string GetResourceUri(string siteUrl)
        {
            var uri = new Uri(siteUrl);
            return $"{uri.Scheme}://{uri.DnsSafeHost}";
        }

        private string[] GetSharePointResourceScope(string siteUrl, string[] scopes = null)
        {
            string resourceUri = GetResourceUri(siteUrl);
            return scopes == null
                ? new[] { $"{resourceUri}/.default" }
                : scopes.Select(scope => $"{resourceUri}/{scope}").ToArray();
        }


        private ClientContext GetClientContextInternal(string siteUrl, string[] scopes = null)
        {
            siteUrl ??= _configuration.GetValue<string>("SharePoint:Url");
            if (string.IsNullOrEmpty(siteUrl))
                throw new Exception("The SharePoint site URL is not specified or configured");

            // Acquire the access token.
            string[] effectiveScopes = GetSharePointResourceScope(siteUrl, scopes);
            var clientContext = new ClientContext(siteUrl);
            clientContext.ExecutingWebRequest += (object sender, WebRequestEventArgs e) =>
            {
                string accessToken = _tokenAcquisition.GetAccessTokenForUserAsync(effectiveScopes).GetAwaiter().GetResult();
                e.WebRequestExecutor.RequestHeaders.Add("Authorization", $"Bearer {accessToken}");
            };
            return clientContext;
        }

        public ClientContext GetClientContext(string siteUrl = null)
        {
            return GetClientContextInternal(siteUrl);
        }
    }
}

Let's configure our services

Open the Startup.cs file and modify the ConfigureServices() method to look like:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMicrosoftWebAppAuthentication(Configuration)
                .AddMicrosoftWebAppCallsWebApi(Configuration, new string[] { "User.Read" })
                .AddDistributedTokenCaches();

        services.AddDistributedMemoryCache();

        services.AddSharePointContextFactory();

            services.AddControllersWithViews(options =>
        {
            var policy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .Build();
            options.Filters.Add(new AuthorizeForScopesAttribute() { Scopes = new[] { "User.Read" } });
            options.Filters.Add(new AuthorizeFilter(policy));
        });
        services.AddRazorPages();
    }

Add the necessary using clauses at the top of the file

using Microsoft.Identity.Web;
using Microsoft.Identity.Web.TokenCacheProviders.Distributed;
using MVC_CSOM.Services;

And add the following line in Configure() method

// ...
    app.UseAuthentication();
// add just before app.UseAuthorization();
//...

Let's (finally) use CSOM

Finally, the code we are really interested in, let's use CSOM, and also that awesome dependency injection mechanism. In the /Controllers/HomeController.cs file, let's modify the code to look like :

using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MVC_CSOM.Models;
using MVC_CSOM.Services;

namespace MVC_CSOM.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly ISharePointClientContextFactory _spClientContextFactory;

        public HomeController(ILogger<HomeController> logger, ISharePointClientContextFactory spClientContextFactory)
        {
            _spClientContextFactory = spClientContextFactory;
            _logger = logger;
        }

        public async Task<IActionResult> Index()
        {
            using (var ctx = _spClientContextFactory.GetClientContext())
            {
                ctx.Load(ctx.Web);
                await ctx.ExecuteQueryAsync();
                ViewBag.WebTitle = ctx.Web.Title;
                return View();
            }
        }

        public IActionResult Privacy()
        {
            return View();
        }

        [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

If you are not familiar with the Dependency Injection mechanism that takes place here. Here is what happens: Earlier in the Startup.cs file, we called services.AddSharePointContextFactory(); meaning that we want an instance of a SharePoint Context Factory (ISharePointClientContextFactory) wherever it is requested. In the constructor of our controller, we mention we need an instance of that interface. The mechanism then get the relevant instance and passes it to the controller when it is instantiated. From a user code perspective, there is nothing more we need to know. We get the dependency we need, PERIOD.

That SharePointClientContextFactory is a class we wrote earlier, you can take a look back to see what it is doing :)

And here we go, CSOM can be used with the rights of the logged in user in our MVC Web app ! In VS Code, you can hit F5 (and select .NET Core on first time) to run your app in debug mode.

Note that I use here a SharePointClientContextFactory that has a GetContext() method. This method can either take no arguments and get the target site URL from the configuration file, or take the target site URL as its parameter. It would be the way to go if you need a client context on multiple different sites.

However, if there is only one site you plan to target, and it is configured in the appsettings.json file. Exactly the case we have right now. Here is a second approach:

Do the following changes:

In Startup.cs

// Replace services.AddSharePointContextFactory(); by
services.AddCurrentUserSharePointClientContext();

In /Controllers/HomeController.cs

//...
 public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly ClientContext ctx;

        public HomeController(ILogger<HomeController> logger, ClientContext clientContext)
        {
            ctx = clientContext;
            _logger = logger;
        }

        public async Task<IActionResult> Index()
        {
            ctx.Load(ctx.Web);
            await ctx.ExecuteQueryAsync();
            ViewBag.WebTitle = ctx.Web.Title;
            return View();
        }
//...

The result is exactly the same but, here you simply say: "Hey! gimme the Client Context for current user" and use it!

NOTE: Don't worry if you see Microsoft.Identity.Client.MsalUiRequiredException raised from time to time if you relaunch the app multiple times. The reason is because the cookie in your browser is no longer in sync with the server that caches the tokens in memory. In Production scenarios, it is recommended to use real distributed cache (e.g. Redis, or other dbms).

Have fun !

You are now able to get some fun writing ASP .NET Core MVC apps and working against SharePoint Online using CSOM, how cool is that?! The project sample can be found here on GitHub Hopefully, you found this post useful! Feel free to comment and provide your feedback !

Cheers,

Yannick

Other posts