SharePoint CSOM ya disponible en .NET Standard
A finales de Junio, se publicó en una versión estable y oficial de lo que ha sido uno de los grandes requerimientos de los desarrolladores de Office en los últimos años: CSOM para .NET Standard o lo que es lo mismo la librería Cliente para poder acceder a SharePoint. En este artículo vamos a explicar los motivos por los que se hacia esta demanda y veremos un ejemplo sencillo de como empezar a usarlo.
Historia
En estos momentos hablar de la importancia que tiene que una librería este en una determinada tecnología u otra mirándolo desde la perspectiva de cualquier tecnólogo puede parecer que carezca de sentido sino se tiene el contexto, y.. ¿cuál es el contexto? La forma en la que desarrolla en los productos Microsoft ha evolucionado mucho en los últimos años. Microsoft dispone de muchos productos que se utilizan en muchísimos clientes como son SharePoint, Dynamics CRM, … Estos productos hace no muchos años se instalan o instalaban en la infraestructura de estos clientes y el desarrollo se realizaba instalando “artefactos” en dicho servidor. Ahora bien no es necesario indicar que los tiempos cambian y estos productos ahora son servicios que consumimos en el Cloud y naturalmente no tenemos acceso al servidor para instalar nada. Esto implico un gran cambio en la forma de desarrollar sobre SharePoint, pasamos de un desarrollo en lenguajes de servidor como C# a especialistas en el FrontEnd. La cuestión no es solo un cambio de tecnología cambiar C# por Typescript (con el frameworks que más rabia nos dé), sino que muchas de las acciones que se hacían en los desarrollos ahora no se pueden hacer. Acciones como poder impersonar usuarios al estar en el Front no se puede hacer (y aunque se pudiera no sería una opción que se debería de plantear), procesos “programados”/“bajo demanda” para integración con otros sistemas. Para poder hacer esta funcionalidad había que utilizar CSOM( Client SharePoint Object Model) o bien directamente llamadas a la API Rest de SharePoint. La problemática en este punto es que la API Rest de SharePoint no tiene todos los endpoint ni toda la funcionalidad que hay en CSOM y a parte CSOM es mucho más fácil de utilizar. En este punto CSOM fue relativamente importante tanto para añadirlo en API Rest Custom o bien en el uso de Azure Functions. En las primeras versiones cuando Azure Functions en su versión 1.0 era .NET Framework y .NET Core estaba en sus primeras versiones (que había muchas dudas sobre su evolución y el camino que iba a seguir) el desarrollador de Office podía extender su desarrollo de una forma natural. Conforme aumentaron las versiones de dichos productos y su implantación fue mayor, el poder hacer uso de una Azure Function en el contexto de SharePoint era algo relativamente complejo. Muchos en este punto pensareis que necesidades pueda tener de hacer uso de SharePoint en una Azure Functions, poner el caso de que tengo unos documentos en SharePoint y quiero mandarlos en un almacenamiento más ligero para tenerlo en un historio como pudiera ser un Blob Storage. Para hacer este desarrollo teníamos dos opciones:
- Hacer uso de Azure Functions en versión 1.0 (con sus contras: obsoleto, tuning del toolings de herramientas)
- Hacer uso de Azure Functions en versión 2.0 o superior, pero tener en cuenta que la comunicación con todo lo relacionado con SharePoint se tendria que poner una “pieza”(API Rest) implementada en .NET Framework para poder hacer esta forma.
Como podéis ver cualquiera de estas opciones no es la mejor, y que independientemente de la que escojamos elegimos la menos mala. Ahora bien, con la llegada de CSOM en .NET Standard todos estos impedimentos ya nos los tenemos, ahora tenemos otras cosas. Las primeras preguntas que tenemos son ¿CSOM en .NET Framework y Standard tienen las mismas opciones? ¿Son iguales?
¿Qué cosas podemos hacer con CSOM en .NET Core o más bien cuáles son sus limitaciones?
Lo primero que indica la documentación es que esta versión de CSOM solo es compatible con la versión Online de SharePoint. En las versiones OnPremise no está actualizada ni soportada.
El principal cambio del uso de esta librería es que no soporta ningún método que tenga la autenticación SharePointOnlineCredentials, el uso de la autenticación basada en usuario/contraseña es un método común para los programadores que usan CSOM para .NET Framework. En CSOM para .NET Standard esto ya no es posible, es el desarrollador que usa CSOM para .NET Standard para obtener un token de acceso de OAuth y usarlo para realizar llamadas a SharePoint Online. El método recomendado para obtener tokens de acceso para SharePoint Online es configurar una aplicación de Azure AD. Para CSOM para .NET Standard lo único que importa es que se obtenga un token de acceso válido, ya que puede usar el flujo de credenciales de la contraseña del propietario del recurso, mediante el inicio de sesión de dispositivo, mediante la autenticación basada en certificados,
En el tema de métodos de CSOM no se han reescrito:
- SaveBinaryDirect / OpenBinaryDirectAPI (basada en WebDAV)=> El motivo es claro para poder llegar al fichero es necesario tener una autenticación que hoy en día no está permitida. El uso de estos métodos se hacen dependiendo del tamaño de ficheros que queremos realizar (esto daría para un artículo solamente).
- Microsoft.SharePoint.Client.Utilities.HttpUtility => Todos estos métodos que se utilizaban están escritos en el propio Framework con lo cual han hecho caso al no tener dos librerías que hacen lo mismo
- EventReceiver => Aunque parezca mentira aún estaban este tipo de Artefactos soportados en el Frameworks, aunque todo el mundo ya usaba como desencadenadores otros artefactos de Azure como las mencionadas Azure Functions, Logic Apps .. etc
Veamos un ejemplo
Crear la aplicación en el Azure Active Directory
- Ir a Azure AD portal a través de https://aad.portal.azure.com
- Haga clic en Azure Active Directory y en los registros de aplicaciones en el panel de navegación izquierdo
- Haga clic en 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, haga clic en Agregar permiso, elija SharePoint, permisos delegados y seleccione por ejemplo AllSites. administrar
Haga clic en conceder consentimiento de administrador para aceptar los permisos solicitados de la aplicación
- Haga clic en autenticación en el panel de navegación izquierdo
- Cambiar tipo de cliente predeterminado-tratar aplicación como cliente público de no a sí
- Haga clic en información general y copie el identificador de la aplicación en el portapapeles (lo necesitaremos más adelante).
Crear la Aplicación de Consola
- Crearemos una aplicación de consola. Bien usando la línea de comando o bien directamente desde Visual Studio
dotnet new console
- Añadiremos los siguientes paquetes de nuget . Microsoft.SharePointOnline.CSOM . System.IdentityModel.Token.Jwt
- Nos creamos una clase autenticacion manager
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 Example_CSOM_NET_Standar
{
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 = "clientid";
// 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 wi = (TokenWaitInfo)state;
if (wi.Handle != null)
{
wi.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);
}
}
}
- Con esta clase ya podemos obtener un Token valido y hacer consultas contra SharePoint. Por ejemplo:
public static async Task Main(string[] args)
{
Console.WriteLine("Introduce el tenant");
var tenant = Console.ReadLine();
Console.WriteLine("Introduce el usuario");
var user = Console.ReadLine();
Console.WriteLine("Introduce el passworkd");
var rawPassword = Console.ReadLine();
Uri site = new Uri($"https://{tenant}.sharepoint.com/");
SecureString password = new SecureString();
foreach (char c in rawPassword) password.AppendChar(c);
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}");
}
}
y el resultado seria el siguiente
Conclusiones
Que CSOM lo podamos utilizar en NET Standard es un gran paso para todos los desarrolladores de SharePoint, con la llegada de .NET 5 y los cambios que implican era algo que era cuestión de tiempo que acabara sucediendo sin embargo su adelanto es algo de gran ayuda para todos los desarrolladores de Office. A nivel de aprendizaje los métodos son exactamente iguales y por lo tanto no requiere de ningún aprendizaje extra.
Happy codding
Codigo Fuente en Github
https://github.com/AdrianDiaz81/Example-CSOM-NET-Standar
Referencias
https://docs.microsoft.com/es-es/sharepoint/dev/sp-add-ins/using-csom-for-dotnet-standard