Usar el CSOM para .NET Standard en lugar del CSOM para .NET Framework

Puede usar el modelo de objetos de cliente (CSOM) de SharePoint para recuperar, actualizar y administrar datos en SharePoint. SharePoint ofrece el CSOM de varias formas:

  • Ensamblados redistribuibles de .NET Framework
  • Ensamblados redistribuibles de .NET Standard
  • Biblioteca de JavaScript (JSOM)
  • Extremos REST/OData

En este artículo, nos centraremos en explicar cuáles son las diferencias entre la versión .NET Framework y la versión redistribuible .NET Standard. En muchos casos, ambas versiones son idénticas y, si ha estado escribiendo código con la versión de .NET Framework, entonces ese código y todo lo que haya aprendido seguirá siendo relevante, en la mayoría de los casos, al trabajar con la versión .NET Standard.

Diferencias clave entre la versión .NET Framework y la versión .NET Standard

En la tabla siguiente, se describen las diferencias entre ambas versiones y se proporcionan instrucciones acerca de cómo manejar las diferencias.

Característica CSOM Versión .NET Framework Versión .NET Standard Instrucciones
Compatibilidad .NET .NET Framework 4.5 y versiones superiores .NET Framework 4.6.1 y versiones superiores, .NET Core 2.0 y versiones superiores, Mono 5.4 y versiones superiores (documentos de .NET) Se recomienda usar el CSOM para la versión .NET Standard para todos los desarrollos de CSOM de SharePoint Online.
Multiplataforma No Sí (puede usarse en cualquier plataforma compatible con .NET Standard) Para multiplataforma, tiene que usar el CSOM para .NET Standard.
Soporte técnico de SharePoint en el entorno local No Las versiones .NET Framework del CSOM aún son completamente compatibles y se encuentran en constante actualización. Por tanto, use esas versiones para el desarrollo de SharePoint local.
Soporte técnico para los flujos de autenticación heredada (denominada autenticación basada en cookies usando la clase SharePointOnlineCredentials) No Consulte el capítulo de Uso de autenticación moderna con CSOM para .NET Standard. El método recomendado es usar aplicaciones de Azure AD para configurar la autenticación de SharePoint Online.
API SaveBinaryDirect / OpenBinaryDirect (basadas en WebDAV) No Use las API de archivo regulares en CSOM, ya que no se recomienda usar las API BinaryDirect, ni siquiera cuando se usa la versión .NET Framework
Clase Microsoft.SharePoint.Client.Utilities.HttpUtility No Cambiar a clases similares en .NET, como System.Web.HttpUtility
Espacio de nombres Microsoft.SharePoint.Client.EventReceivers No Cambiar a conceptos de eventos modernos como webhook

Nota:

La versión estándar de .NET de los ensamblados CSOM se incluye en el paquete de NuGet existente denominado Microsoft.SharePointOnline.CSOM a partir de la versión 16.1.20211.12000. El ejemplo siguiente requiere esta versión o superior para funcionar en un proyecto de destino de .Net core o estándar.

Usar la autenticación moderna con CSOM para .NET Standard

El uso de la autenticación basada en un usuario y contraseña, implementada a través de la clase SharePointOnlineCredentials, es un enfoque común para los desarrolladores que usan CSOM para .NET Framework. Cuando se usa el CSOM para .NET Standard, esto ya no es posible. Obtener un token de acceso de OAuth y usarlo al realizar llamadas a SharePoint Online depende del desarrollador que use el CSOM para .NET Standard. El método recomendado para obtener los tokens de acceso para SharePoint Online es configurar una aplicación de Azure AD. En el caso de CSOM para .NET Standard, lo único que importa es que se consiga un token de acceso válido, ya sea usar el flujo de credenciales de la contraseña del propietario del recurso, usar el inicio de sesión del dispositivo, usar la autenticación basada en certificados,...

En este capítulo, usaremos un flujo de credenciales de la contraseña del propietario del recurso de OAuth que tiene como resultado un token de acceso de OAuth. Después, el CSOM lo usará para autenticar las solicitudes en SharePoint Online, ya que imita el comportamiento de la clase SharePointOnlineCredentials.

Configurar una aplicación en Azure AD

A continuación, se indican los pasos que le ayudarán a crear y configurar una aplicación en Azure Active Directory:

  • Vaya al portal de Azure AD a través de https://aad.portal.azure.com
  • Seleccione Azure Active Directory y, en Registros de aplicaciones, en el panel de navegación izquierdo
  • Seleccione Nuevo registro
  • Escriba un nombre para la aplicación y haga clic en Registrar
  • Vaya a Permisos de la API para conceder permisos a la aplicación, seleccione Agregar un permiso, elija SharePoint, Permisos delegados, y seleccione, por ejemplo, AllSites.Manage
  • Seleccione Conceder permiso de administrador para aceptar los permisos solicitados por la aplicación
  • Seleccione Autenticación en el panel de navegación izquierdo
  • Cambie Permitir flujos de clientes públicos de No a
  • Seleccione Información general y copie el identificador de la aplicación en el portapapeles (lo necesitará más adelante)

Obtener un token de acceso de Azure AD y usarlo en la aplicación basada en el CSOM para .NET Standard

Cuando se usa CSOM para .NET Standard, el desarrollador tiene la responsabilidad de obtener un token de acceso para SharePoint Online y asegurar que se inserte en cada llamada realizada a SharePoint Online. A continuación, se muestra un modelo de código común para lograr esto:

public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword)
{
    context.ExecutingWebRequest += (sender, e) =>
    {
        // Get an access token using your preferred approach
        string accessToken = MyCodeToGetAnAccessToken(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password);
        // Insert the access token in the request
        e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
    };
}

La ClientContext obtenida a través del método GetContext puede usarse igual que cualquier otro ClientContext y funcionará con todos los código existentes. Debajo de los fragmentos de código, se muestra una clase auxiliar y una aplicación de consola que usan la clase auxiliar. La reutilización de estas clases facilitará la implementación de un equivalente para la clase SharePointOnlineCredentials.

Nota:

La biblioteca principal de sitios PnP tiene una clase similar a AuthenticationManager que es compatible con muchos otros flujos de autenticación basados en Azure AD.

Ejemplo de aplicación de consola

public static async Task Main(string[] args)
{
    Uri site = new Uri("https://contoso.sharepoint.com/sites/siteA");
    string user = "joe.doe@contoso.onmicrosoft.com";
    SecureString password = GetSecureString($"Password for {user}");

    // Note: The PnP Sites Core AuthenticationManager class also supports this
    using (var authenticationManager = new AuthenticationManager())
    using (var context = authenticationManager.GetContext(site, user, password))
    {
        context.Load(context.Web, p => p.Title);
        await context.ExecuteQueryAsync();
        Console.WriteLine($"Title: {context.Web.Title}");
    }
}

Clase de ejemplo AuthenticationManager

Nota:

Actualizar el defaultAADAppId con el ID. de aplicación de la aplicación que se ha registrado en Azure AD

Nota:

Si usa CSOM para .NET Standard con la versión 3 de Azure Functions, es posible que se produzca un error de tipo runtime relacionado con System.IdentityModel.Tokens.Jwt. Para solucionar este problema, siga esta solución alternativa.

using Microsoft.SharePoint.Client;
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Security;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

namespace CSOMDemo
{
    public class AuthenticationManager: IDisposable
    {
        private static readonly HttpClient httpClient = new HttpClient();
        private const string tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/token";

        private const string defaultAADAppId = "986002f6-c3f6-43ab-913e-78cca185c392";

        // Token cache handling
        private static readonly SemaphoreSlim semaphoreSlimTokens = new SemaphoreSlim(1);
        private AutoResetEvent tokenResetEvent = null;
        private readonly ConcurrentDictionary<string, string> tokenCache = new ConcurrentDictionary<string, string>();
        private bool disposedValue;

        internal class TokenWaitInfo
        {
            public RegisteredWaitHandle Handle = null;
        }

        public ClientContext GetContext(Uri web, string userPrincipalName, SecureString userPassword)
        {
            var context = new ClientContext(web);

            context.ExecutingWebRequest += (sender, e) =>
            {
                string accessToken = EnsureAccessTokenAsync(new Uri($"{web.Scheme}://{web.DnsSafeHost}"), userPrincipalName, new System.Net.NetworkCredential(string.Empty, userPassword).Password).GetAwaiter().GetResult();
                e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + accessToken;
            };

            return context;
        }


        public async Task<string> EnsureAccessTokenAsync(Uri resourceUri, string userPrincipalName, string userPassword)
        {
            string accessTokenFromCache = TokenFromCache(resourceUri, tokenCache);
            if (accessTokenFromCache == null)
            {
                await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                try
                {
                    // No async methods are allowed in a lock section
                    string accessToken = await AcquireTokenAsync(resourceUri, userPrincipalName, userPassword).ConfigureAwait(false);
                    Console.WriteLine($"Successfully requested new access token resource {resourceUri.DnsSafeHost} for user {userPrincipalName}");
                    AddTokenToCache(resourceUri, tokenCache, accessToken);

                    // Register a thread to invalidate the access token once's it's expired
                    tokenResetEvent = new AutoResetEvent(false);
                    TokenWaitInfo wi = new TokenWaitInfo();
                    wi.Handle = ThreadPool.RegisterWaitForSingleObject(
                        tokenResetEvent,
                        async (state, timedOut) =>
                        {
                            if (!timedOut)
                            {
                                TokenWaitInfo internalWaitToken = (TokenWaitInfo)state;
                                if (internalWaitToken.Handle != null)
                                {
                                    internalWaitToken.Handle.Unregister(null);
                                }
                            }
                            else
                            {
                                try
                                {
                                    // Take a lock to ensure no other threads are updating the SharePoint Access token at this time
                                    await semaphoreSlimTokens.WaitAsync().ConfigureAwait(false);
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                    Console.WriteLine($"Cached token for resource {resourceUri.DnsSafeHost} and user {userPrincipalName} expired");
                                }
                                catch (Exception ex)
                                {
                                    Console.WriteLine($"Something went wrong during cache token invalidation: {ex.Message}");
                                    RemoveTokenFromCache(resourceUri, tokenCache);
                                }
                                finally
                                {
                                    semaphoreSlimTokens.Release();
                                }
                            }
                        },
                        wi,
                        (uint)CalculateThreadSleep(accessToken).TotalMilliseconds,
                        true
                    );

                    return accessToken;

                }
                finally
                {
                    semaphoreSlimTokens.Release();
                }
            }
            else
            {
                Console.WriteLine($"Returning token from cache for resource {resourceUri.DnsSafeHost} and user {userPrincipalName}");
                return accessTokenFromCache;
            }
        }

        private async Task<string> AcquireTokenAsync(Uri resourceUri, string username, string password)
        {
            string resource = $"{resourceUri.Scheme}://{resourceUri.DnsSafeHost}";

            var clientId = defaultAADAppId;
            var body = $"resource={resource}&client_id={clientId}&grant_type=password&username={HttpUtility.UrlEncode(username)}&password={HttpUtility.UrlEncode(password)}";
            using (var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"))
            {

                var result = await httpClient.PostAsync(tokenEndpoint, stringContent).ContinueWith((response) =>
                {
                    return response.Result.Content.ReadAsStringAsync().Result;
                }).ConfigureAwait(false);

                var tokenResult = JsonSerializer.Deserialize<JsonElement>(result);
                var token = tokenResult.GetProperty("access_token").GetString();
                return token;
            }
        }

        private static string TokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string accessToken))
            {
                return accessToken;
            }

            return null;
        }

        private static void AddTokenToCache(Uri web, ConcurrentDictionary<string, string> tokenCache, string newAccessToken)
        {
            if (tokenCache.TryGetValue(web.DnsSafeHost, out string currentAccessToken))
            {
                tokenCache.TryUpdate(web.DnsSafeHost, newAccessToken, currentAccessToken);
            }
            else
            {
                tokenCache.TryAdd(web.DnsSafeHost, newAccessToken);
            }
        }

        private static void RemoveTokenFromCache(Uri web, ConcurrentDictionary<string, string> tokenCache)
        {
            tokenCache.TryRemove(web.DnsSafeHost, out string currentAccessToken);
        }

        private static TimeSpan CalculateThreadSleep(string accessToken)
        {
            var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(accessToken);
            var lease = GetAccessTokenLease(token.ValidTo);
            lease = TimeSpan.FromSeconds(lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds > 0 ? lease.TotalSeconds - TimeSpan.FromMinutes(5).TotalSeconds : lease.TotalSeconds);
            return lease;
        }

        private static TimeSpan GetAccessTokenLease(DateTime expiresOn)
        {
            DateTime now = DateTime.UtcNow;
            DateTime expires = expiresOn.Kind == DateTimeKind.Utc ? expiresOn : TimeZoneInfo.ConvertTimeToUtc(expiresOn);
            TimeSpan lease = expires - now;
            return lease;
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    if (tokenResetEvent != null)
                    {
                        tokenResetEvent.Set();
                        tokenResetEvent.Dispose();
                    }
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}