|
| 1 | +--- |
| 2 | +title: Using CSOM for .Net Standard instead of CSOM for .Net Framework |
| 3 | +description: Explains the differences between using CSOM for .Net Standard versus CSOM for .Net Framework |
| 4 | +ms.date: 6/15/2020 |
| 5 | +ms.prod: sharepoint |
| 6 | +localization_priority: Priority |
| 7 | +--- |
| 8 | + |
| 9 | +# Using CSOM for .NET Standard instead of CSOM for .NET Framework |
| 10 | + |
| 11 | +You can use the SharePoint client object model (CSOM) to retrieve, update, and manage data in SharePoint. SharePoint makes the CSOM available in several forms: |
| 12 | + |
| 13 | +- .NET Framework redistributable assemblies |
| 14 | +- .NET Standard redistributable assemblies |
| 15 | +- JavaScript library (JSOM) |
| 16 | +- REST/OData endpoints |
| 17 | + |
| 18 | +In this article we'll focus on explaining what the differences are between the .NET Framework version and the .NET Standard version redistributable. In many ways both versions are identical and if you've been writing code using the .NET Framework version then that code and everything you've learned will for the most part still be relevant when working with the .NET Standard version. |
| 19 | + |
| 20 | +## Key differences between the .NET Framework version and the .NET Standard version |
| 21 | + |
| 22 | +Below table outlines the differences between both versions and provides guidelines on how to handle the differences. |
| 23 | + |
| 24 | +CSOM feature | .NET Framework version | .NET Standard version | Guidelines |
| 25 | +-------------|------------------------|-----------------------|------------ |
| 26 | +.NET supportability | .NET Framework 4.5+ | .NET Framework 4.6.1+, .NET Core 2.0+, Mono 5.4+ (see https://docs.microsoft.com/en-us/dotnet/standard/net-standard) | It's recommended to use the CSOM for .NET Standard version for all your CSOM developments |
| 27 | +Cross platform | No | Yes (can be used on any platform that support .NET Standard) | For cross platform you have to use CSOM for .NET Standard |
| 28 | +On-Premises SharePoint support | Yes | No | The CSOM .NET Framework versions are still fully supported and being updated, so use those for on-premises SharePoint development |
| 29 | +Support for legacy authentication flows (so called cookie based auth using the `SharePointOnlineCredentials` class) | Yes | No | See the **Using modern authentication with CSOM for .NET Standard** chapter. Using Azure AD applications to configure authentication for SharePoint Online is the recommended approach |
| 30 | +`SaveBinaryDirect` / `OpenBinaryDirect` API's (webdav based) | Yes | No | Use the regular file API's in CSOM as it's not recommended to use the BinaryDirect API's, even not when using the .NET Framework version |
| 31 | +`Microsoft.SharePoint.Client.Utilities.HttpUtility` class | Yes | No | Switch to similar classes in .NET such as `System.Web.HttpUtility` |
| 32 | +`Microsoft.SharePoint.Client.EventReceivers` namespace | Yes | No | Switch to modern eventing concepts such as [Web Hooks](../apis/webhooks/get-started-webhooks.md). |
| 33 | + |
| 34 | +## Using modern authentication with CSOM for .NET Standard |
| 35 | + |
| 36 | +Using user/password based authentication, implemented via the `SharePointOnlineCredentials` class, is a common approach for developers using CSOM for .NET Framework. In CSOM for .NET Standard this is not possible anymore, it's up to the developer using CSOM for .NET Standard to obtain an OAuth access token and use that when making calls to SharePoint Online. The recommended approach for getting access tokens for SharePoint Online is by setting up an Azure AD application. For CSOM for .NET Standard the only thing that matters is that you obtain a valid access token, this can be using resource owner password credential flow, using device login, using certificate based auth,... |
| 37 | + |
| 38 | +In this chapter we'll use an OAuth resource owner password credential flow resulting in an OAuth access token that then is used by CSOM for authenticating requests against SharePoint Online as that mimics the behavior of the `SharePointOnlineCredentials` class. |
| 39 | + |
| 40 | +### Configuring an application in Azure AD |
| 41 | + |
| 42 | +Below steps will help you create and configure an application in Azure Active Directory: |
| 43 | + |
| 44 | +- Go to Azure AD Portal via https://aad.portal.azure.com |
| 45 | +- Click on **Azure Active Directory** and on **App registrations** in the left navigation |
| 46 | +- Click on **New registration** |
| 47 | +- Enter a name for your application and click on **Register** |
| 48 | +- Go to **API permissions** to grant permissions to your application, click on **Add a permission**, choose **SharePoint**, **Delegated permissions** and select for example **AllSites.Manage** |
| 49 | +- Click on **Grant admin consent** to consent the application's requested permissions |
| 50 | +- Click on **Authentication** in the left navigation |
| 51 | +- Change **Default client type - Treat application as public client** from No to **Yes** |
| 52 | +- Click on **Overview** and copy the application id to the clipboard (you'll need it later on) |
| 53 | + |
| 54 | +### Getting an access token from Azure AD and using that in your CSOM for .NET Standard based application |
| 55 | + |
| 56 | +When using CSOM for .NET Standard it's the responsibility of the developer to obtain an access token for SharePoint Online and ensure it's inserted into each call made to SharePoint Online. A common code pattern to realize this is shown below: |
| 57 | + |
| 58 | +```csharp |
| 59 | +public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword) |
| 60 | +{ |
| 61 | + var context = new ClientContext(web) |
| 62 | + { |
| 63 | + // Important to turn off FormDigestHandling when using access tokens |
| 64 | + FormDigestHandlingEnabled = false |
| 65 | + }; |
| 66 | + |
| 67 | + context.ExecutingWebRequest += (sender, e) => |
| 68 | + { |
| 69 | + // Get an access token using your preferred approach |
| 70 | + string accessToken = MyCodeToGetAnAccessToken(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password); |
| 71 | + // Insert the access token in the request |
| 72 | + e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken; |
| 73 | + }; |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +The `ClientContext` obtained via the `GetContext` method can be used like any other `ClientContext` and will work with all your existing code. Below code snippets show a helper class and console app using the helper class, reusing these classes will make it easy to implement an equivalent for the `SharePointOnlineCredentials` class. |
| 78 | + |
| 79 | +> [!Note] |
| 80 | +> The [PnP Sites Core library](https://github.com/pnp/PnP-Sites-Core) has a [similar AuthenticationManager class](https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/AuthenticationManager.cs) that supports many more Azure AD based authentication flows. |
| 81 | +
|
| 82 | +#### Console app sample |
| 83 | + |
| 84 | +```csharp |
| 85 | +public static async Task Main(string[] args) |
| 86 | +{ |
| 87 | + Uri site = new Uri("https://contoso.sharepoint.com/sites/siteA"); |
| 88 | + string user = "[email protected]"; |
| 89 | + SecureString password = GetSecureString($"Password for {user}"); |
| 90 | + |
| 91 | + // Note: The PnP Sites Core AuthenticationManager class also supports this |
| 92 | + using (var authenticationManager = new AuthenticationManager()) |
| 93 | + using (var context = authenticationManager.GetContext(site, user, password)) |
| 94 | + { |
| 95 | + context.Load(context.Web, p => p.Title); |
| 96 | + await context.ExecuteQueryAsync(); |
| 97 | + Console.WriteLine($"Title: {context.Web.Title}"); |
| 98 | + } |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +#### AuthenticationManager sample class |
| 103 | + |
| 104 | +> [!Note] |
| 105 | +> Update the defaultAADAppId with the application id of the app you've registered in Azure AD |
| 106 | +
|
| 107 | +```csharp |
| 108 | +using Microsoft.SharePoint.Client; |
| 109 | +using System; |
| 110 | +using System.Collections.Concurrent; |
| 111 | +using System.Net.Http; |
| 112 | +using System.Security; |
| 113 | +using System.Text; |
| 114 | +using System.Text.Json; |
| 115 | +using System.Threading; |
| 116 | +using System.Threading.Tasks; |
| 117 | +using System.Web; |
| 118 | + |
| 119 | +namespace CSOMDemo |
| 120 | +{ |
| 121 | + public class AuthenticationManager: IDisposable |
| 122 | + { |
| 123 | + private static readonly HttpClient httpClient = new HttpClient(); |
| 124 | + private const string tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token"; |
| 125 | + |
| 126 | + private const string defaultAADAppId = "986002f6-c3f6-43ab-913e-78cca185c392"; |
| 127 | + |
| 128 | + // Token cache handling |
| 129 | + private static readonly SemaphoreSlim semaphoreSlimTokens = new SemaphoreSlim(1); |
| 130 | + private AutoResetEvent tokenResetEvent = null; |
| 131 | + private readonly ConcurrentDictionary<string, string> tokenCache = new ConcurrentDictionary<string, string>(); |
| 132 | + private bool disposedValue; |
| 133 | + |
| 134 | + internal class TokenWaitInfo |
| 135 | + { |
| 136 | + public RegisteredWaitHandle Handle = null; |
| 137 | + } |
| 138 | + |
| 139 | + public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword) |
| 140 | + { |
| 141 | + var context = new ClientContext(web) |
| 142 | + { |
| 143 | + // Important to turn off FormDigestHandling when using access tokens |
| 144 | + FormDigestHandlingEnabled = false |
| 145 | + }; |
| 146 | + |
| 147 | + context.ExecutingWebRequest += (sender, e) => |
| 148 | + { |
| 149 | + string accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password).GetAwaiter().GetResult(); |
| 150 | + e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken; |
| 151 | + }; |
| 152 | + |
| 153 | + return context; |
| 154 | + } |
| 155 | + |
| 156 | + |
| 157 | + public async Task<string> EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword) |
| 158 | + { |
| 159 | + string accessTokenFromCache = TokenFromCache(resourceUri, tokenCache); |
| 160 | + if (accessTokenFromCache == null) |
| 161 | + { |
| 162 | + await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false); |
| 163 | + try |
| 164 | + { |
| 165 | + // No async methods are allowed in a lock section |
| 166 | + string accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword).ConfigureAwait(false); |
| 167 | + Console.WriteLine($"Successfully requested new access token resource {resourceUri.DnsSafeHost} for user {userPrincipalName}"); |
| 168 | + AddTokenToCache(resourceUri, tokenCache, accessToken); |
| 169 | + |
| 170 | + // Register a thread to invalidate the access token once's it's expired |
| 171 | + tokenResetEvent = new AutoResetEvent(false); |
| 172 | + TokenWaitInfo wi = new TokenWaitInfo(); |
| 173 | + wi.Handle = ThreadPool.RegisterWaitForSingleObject( |
| 174 | + tokenResetEvent, |
| 175 | + async (state, timedOut) => |
| 176 | + { |
| 177 | + if (!timedOut) |
| 178 | + { |
| 179 | + TokenWaitInfo wi = (TokenWaitInfo)state; |
| 180 | + if (wi.Handle != null) |
| 181 | + { |
| 182 | + wi.Handle.Unregister(null); |
| 183 | + } |
| 184 | + } |
| 185 | + else |
| 186 | + { |
| 187 | + try |
| 188 | + { |
| 189 | + // Take a lock to ensure no other threads are updating the SharePoint Access token at this time |
| 190 | + await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false); |
| 191 | + RemoveTokenFromCache(resourceUri, tokenCache); |
| 192 | + Console.WriteLine($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired"); |
| 193 | + } |
| 194 | + catch (Exception ex) |
| 195 | + { |
| 196 | + Console.WriteLine($"Something went wrong during cache token invalidation: {ex.Message}"); |
| 197 | + RemoveTokenFromCache(resourceUri, tokenCache); |
| 198 | + } |
| 199 | + finally |
| 200 | + { |
| 201 | + semaphoreSlimTokens.Release(); |
| 202 | + } |
| 203 | + } |
| 204 | + }, |
| 205 | + wi, |
| 206 | + (uint)CalculateThreadSleep(accessToken).TotalMilliseconds, |
| 207 | + true |
| 208 | + ); |
| 209 | + |
| 210 | + return accessToken; |
| 211 | + |
| 212 | + } |
| 213 | + finally |
| 214 | + { |
| 215 | + semaphoreSlimTokens.Release(); |
| 216 | + } |
| 217 | + } |
| 218 | + else |
| 219 | + { |
| 220 | + Console.WriteLine($"Returning token from cache for resource {resourceUri.DnsSafeHost} and user {userPrincipalName}"); |
| 221 | + return accessTokenFromCache; |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + private async Task<string> AcquireTokenAsync(Uri resourceUri, string username, string password) |
| 226 | + { |
| 227 | + string resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}"; |
| 228 | + |
| 229 | + var clientId = defaultAADAppId; |
| 230 | + var body = $"resource={resource}&client_id={clientId}&grant_type=password&username={HttpUtility.UrlEncode(username)}&password={HttpUtility.UrlEncode(password)}"; |
| 231 | + using (var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded")) |
| 232 | + { |
| 233 | + |
| 234 | + var result = await httpClient.PostAsync(tokenEndpoint, stringContent).ContinueWith((response) => |
| 235 | + { |
| 236 | + return response.Result.Content.ReadAsStringAsync().Result; |
| 237 | + }).ConfigureAwait(false); |
| 238 | + |
| 239 | + var tokenResult = JsonSerializer.Deserialize<JsonElement>(result); |
| 240 | + var token = tokenResult.GetProperty("access_token").GetString(); |
| 241 | + return token; |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + private static string TokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache) |
| 246 | + { |
| 247 | + if (tokenCache.TryGetValue(web.DnsSafeHost, out string accessToken)) |
| 248 | + { |
| 249 | + return accessToken; |
| 250 | + } |
| 251 | + |
| 252 | + return null; |
| 253 | + } |
| 254 | + |
| 255 | + private static void AddTokenToCache(Uri web, ConcurrentDictionary<string, string> tokenCache, string newAccessToken) |
| 256 | + { |
| 257 | + if (tokenCache.TryGetValue(web.DnsSafeHost, out string currentAccessToken)) |
| 258 | + { |
| 259 | + tokenCache.TryUpdate(web.DnsSafeHost, newAccessToken, currentAccessToken); |
| 260 | + } |
| 261 | + else |
| 262 | + { |
| 263 | + tokenCache.TryAdd(web.DnsSafeHost, newAccessToken); |
| 264 | + } |
| 265 | + } |
| 266 | + |
| 267 | + private static void RemoveTokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache) |
| 268 | + { |
| 269 | + tokenCache.TryRemove(web.DnsSafeHost, out string currentAccessToken); |
| 270 | + } |
| 271 | + |
| 272 | + private static TimeSpan CalculateThreadSleep(string accessToken) |
| 273 | + { |
| 274 | + var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(accessToken); |
| 275 | + var lease = GetAccessTokenLease(token.ValidTo); |
| 276 | + lease = TimeSpan.FromSeconds(lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds > 0 ? lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds : lease.TotalSeconds); |
| 277 | + return lease; |
| 278 | + } |
| 279 | + |
| 280 | + private static TimeSpan GetAccessTokenLease(DateTime expiresOn) |
| 281 | + { |
| 282 | + DateTime now = DateTime.UtcNow; |
| 283 | + DateTime expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn); |
| 284 | + TimeSpan lease = expires - now; |
| 285 | + return lease; |
| 286 | + } |
| 287 | + |
| 288 | + protected virtual void Dispose(bool disposing) |
| 289 | + { |
| 290 | + if (!disposedValue) |
| 291 | + { |
| 292 | + if (disposing) |
| 293 | + { |
| 294 | + if (tokenResetEvent != null) |
| 295 | + { |
| 296 | + tokenResetEvent.Set(); |
| 297 | + tokenResetEvent.Dispose(); |
| 298 | + } |
| 299 | + } |
| 300 | + |
| 301 | + disposedValue = true; |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + public void Dispose() |
| 306 | + { |
| 307 | + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method |
| 308 | + Dispose(disposing: true); |
| 309 | + GC.SuppressFinalize(this); |
| 310 | + } |
| 311 | + } |
| 312 | +} |
| 313 | +``` |
0 commit comments