Gestión de tokens, para dominarlos a todos.

A continuación se presenta un ejemplo de cómo diseñar e implementar una capa de gestión de tokens de acceso (AccessToken) genérica que pueda ser empleada no sólo para la integración con la API de Meta (Instagram Graph API), sino también con otros proveedores externos en el futuro. La idea es desacoplar la lógica de obtención, almacenamiento y refresco de tokens de la lógica específica de un proveedor, permitiendo agregar nuevos proveedores sin modificar la arquitectura principal.

Este es un ejemplo ilustrativo y deberá ajustarse a la infraestructura y convenciones de tu proyecto.

Objetivos

  • Crear un modelo genérico de token capaz de almacenar y gestionar AccessTokens, RefreshTokens, fecha de expiración, etc.
  • Crear una interfaz y una implementación para un almacenamiento de tokens que sea independiente del proveedor.
  • Crear una interfaz de servicio genérico de tokens externos que se pueda especializar para distintos proveedores (Instagram, Facebook, Google, etc.).
  • Integrar este servicio genérico en el adaptador de Instagram, pero sin atarlo a la lógica interna del adaptador.

Entidades y Tipos Genéricos

Primero, definamos una entidad para almacenar información de tokens de distintos proveedores.

// Dominio/Entidades/ExternalAccessToken.cs
public class ExternalAccessToken
{
    public Guid Id { get; private set; }
    public string ProviderName { get; private set; } // Ej: "Instagram", "Facebook", "Google"
    public string AccountIdentifier { get; private set; } // Ej: el userId de Instagram
    public string AccessToken { get; private set; }
    public string RefreshToken { get; private set; }
    public DateTime ExpiresAt { get; private set; }

    protected ExternalAccessToken() { } // Para EF Core

    public ExternalAccessToken(string providerName, string accountIdentifier, string accessToken, DateTime expiresAt, string refreshToken = null)
    {
        Id = Guid.NewGuid();
        ProviderName = providerName;
        AccountIdentifier = accountIdentifier;
        AccessToken = accessToken;
        ExpiresAt = expiresAt;
        RefreshToken = refreshToken;
    }

    public bool IsExpired() => DateTime.UtcNow >= ExpiresAt;

    public void UpdateAccessToken(string newToken, DateTime newExpiration)
    {
        AccessToken = newToken;
        ExpiresAt = newExpiration;
    }

    public void UpdateRefreshToken(string newRefreshToken)
    {
        RefreshToken = newRefreshToken;
    }
}

Repositorio Genérico de Tokens

Este repositorio manejará el almacenamiento y recuperación de tokens sin importar el proveedor. Puede ser implementado con EF Core, Dapper, o cualquier otra tecnología de persistencia.

// Dominio/Interfaces/IExternalAccessTokenRepository.cs
public interface IExternalAccessTokenRepository
{
    Task<ExternalAccessToken> GetByProviderAndAccountAsync(string providerName, string accountIdentifier);
    Task<ExternalAccessToken> GetByIdAsync(Guid id);
    Task InsertAsync(ExternalAccessToken token);
    Task UpdateAsync(ExternalAccessToken token);
}

Interfaz Genérica de Servicio de Tokens Externos

Este servicio se encargará de:

  • Obtener el AccessToken actual (si no está expirado).
  • Refrescarlo si está próximo a expirar o ya expiró.
  • Permitir extender esta lógica con diferentes proveedores.
// Dominio/Servicios/ExternalTokenService/IExternalTokenService.cs
public interface IExternalTokenService
{
    Task<string> GetValidAccessTokenAsync(string providerName, string accountIdentifier);
    Task RefreshAccessTokenAsync(string providerName, string accountIdentifier);
}

Implementación Base del Servicio Genérico

La implementación base delega en métodos abstractos la lógica específica de refrescar el token para cada proveedor. Así, cada proveedor tendrá su propia clase que extienda de esta base y defina cómo se obtiene un nuevo AccessToken.

// Infraestructura/Servicios/ExternalTokenServiceBase.cs
public abstract class ExternalTokenServiceBase : IExternalTokenService
{
    private readonly IExternalAccessTokenRepository _tokenRepository;

    protected ExternalTokenServiceBase(IExternalAccessTokenRepository tokenRepository)
    {
        _tokenRepository = tokenRepository;
    }

    public async Task<string> GetValidAccessTokenAsync(string providerName, string accountIdentifier)
    {
        var token = await _tokenRepository.GetByProviderAndAccountAsync(providerName, accountIdentifier);
        if (token == null)
        {
            throw new Exception($"No token found for provider {providerName} and account {accountIdentifier}");
        }

        // Si el token expira en menos de X tiempo, refrescarlo
        if (token.ExpiresAt <= DateTime.UtcNow.AddMinutes(5))
        {
            await RefreshAccessTokenAsync(providerName, accountIdentifier);
            token = await _tokenRepository.GetByProviderAndAccountAsync(providerName, accountIdentifier);
        }

        return token.AccessToken;
    }

    public async Task RefreshAccessTokenAsync(string providerName, string accountIdentifier)
    {
        var token = await _tokenRepository.GetByProviderAndAccountAsync(providerName, accountIdentifier);
        if (token == null) 
            throw new Exception($"No token found for provider {providerName} and account {accountIdentifier}");

        var (newAccessToken, expiresAt, newRefreshToken) = await RequestNewAccessTokenFromProviderAsync(token);

        token.UpdateAccessToken(newAccessToken, expiresAt);
        if (!string.IsNullOrEmpty(newRefreshToken))
        {
            token.UpdateRefreshToken(newRefreshToken);
        }

        await _tokenRepository.UpdateAsync(token);
    }

    /// <summary>
    /// Cada proveedor tiene su propia lógica para refrescar el token.
    /// Devuelve (AccessToken, ExpiresAt, RefreshToken)
    /// </summary>
    protected abstract Task<(string AccessToken, DateTime ExpiresAt, string RefreshToken)> RequestNewAccessTokenFromProviderAsync(ExternalAccessToken currentToken);
}

Implementación Específica para Meta (Instagram)

Ahora creamos un servicio concreto que hereda de ExternalTokenServiceBase y proporciona la lógica para refrescar el token de Instagram/Meta.

// Infraestructura/Servicios/InstagramTokenService.cs
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;

public class InstagramTokenService : ExternalTokenServiceBase
{
    private readonly HttpClient _httpClient;
    private readonly string _graphApiVersion = "v15.0";

    public InstagramTokenService(IExternalAccessTokenRepository tokenRepository, HttpClient httpClient)
        : base(tokenRepository)
    {
        _httpClient = httpClient;
    }

    protected override async Task<(string AccessToken, DateTime ExpiresAt, string RefreshToken)> RequestNewAccessTokenFromProviderAsync(ExternalAccessToken currentToken)
    {
        // Ejemplo de refresco de token en Meta (Instagram Graph):
        // https://developers.facebook.com/docs/instagram-basic-display-api/refresh-tokens

        // Asumiendo que currentToken.AccessToken es un long-lived token ya obtenido.
        // Para refrescarlo:
        var refreshUrl = $"https://graph.facebook.com/{_graphApiVersion}/oauth/access_token" +
                         $"?grant_type=ig_refresh_token&access_token={currentToken.AccessToken}";

        var response = await _httpClient.GetAsync(refreshUrl);
        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync();

        // Respuesta típica:
        // {
        //   "access_token": "LONG_LIVED_TOKEN",
        //   "token_type": "bearer",
        //   "expires_in": 5184000
        // }
        
        var tokenObj = JsonConvert.DeserializeObject<InstagramRefreshTokenResponse>(json);

        var newAccessToken = tokenObj.AccessToken;
        var expiresAt = DateTime.UtcNow.AddSeconds(tokenObj.ExpiresIn);
        
        // Instagram no siempre devuelve refresh token independiente; el mismo access token se considera renovado.
        return (newAccessToken, expiresAt, null);
    }

    private class InstagramRefreshTokenResponse
    {
        [JsonProperty("access_token")]
        public string AccessToken { get; set; }
        [JsonProperty("expires_in")]
        public int ExpiresIn { get; set; }
    }
}

Uso en el Adaptador de Instagram

Ahora, el InstagramApiAdapter no se preocupa de la lógica de tokens, sólo llama a IExternalTokenService para obtener un token válido antes de hacer las peticiones:

public class InstagramApiAdapter : IInstagramApiService
{
    private readonly HttpClient _httpClient;
    private readonly IExternalTokenService _tokenService;
    private readonly string _facebookGraphVersion = "v15.0";

    public InstagramApiAdapter(HttpClient httpClient, IExternalTokenService tokenService)
    {
        _httpClient = httpClient;
        _tokenService = tokenService;
    }

    public async Task<InstagramInsightsDto> GetAccountMetricsAsync(string igUserId)
    {
        // Obtener token genérico:
        var accessToken = await _tokenService.GetValidAccessTokenAsync("Instagram", igUserId);

        var url = $"https://graph.facebook.com/{_facebookGraphVersion}/{igUserId}/insights" +
                  "?metric=impressions,reach,profile_views&period=day" +
                  $"&access_token={accessToken}";

        var response = await _httpClient.GetAsync(url);
        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync();

        var insightsRoot = JsonConvert.DeserializeObject<InstagramInsightsRoot>(json);
        var result = new InstagramInsightsDto();

        if (insightsRoot?.Data != null)
        {
            foreach (var metric in insightsRoot.Data)
            {
                var value = metric.Values?[0]?.Value ?? 0;
                switch (metric.Name)
                {
                    case "impressions":
                        result.Impressions = value;
                        break;
                    case "reach":
                        result.Reach = value;
                        break;
                    case "profile_views":
                        result.ProfileViews = value;
                        break;
                }
            }
        }

        return result;
    }

    public async Task<InstagramPostPublishResult> PublishImagePostAsync(string igUserId, string imageUrl, string caption)
    {
        var accessToken = await _tokenService.GetValidAccessTokenAsync("Instagram", igUserId);

        // Paso 1: Crear contenedor media
        var createMediaUrl = $"https://graph.facebook.com/{_facebookGraphVersion}/{igUserId}/media";
        var createMediaContent = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            {"image_url", imageUrl},
            {"caption", caption},
            {"access_token", accessToken}
        });

        var mediaResponse = await _httpClient.PostAsync(createMediaUrl, createMediaContent);
        mediaResponse.EnsureSuccessStatusCode();
        var mediaJson = await mediaResponse.Content.ReadAsStringAsync();
        var mediaObj = JsonConvert.DeserializeObject<CreationMediaResponse>(mediaJson);
        var creationId = mediaObj.Id;

        // Paso 2: Publicar el post
        var publishUrl = $"https://graph.facebook.com/{_facebookGraphVersion}/{igUserId}/media_publish";
        var publishContent = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            {"creation_id", creationId},
            {"access_token", accessToken}
        });

        var publishResponse = await _httpClient.PostAsync(publishUrl, publishContent);
        publishResponse.EnsureSuccessStatusCode();
        var publishJson = await publishResponse.Content.ReadAsStringAsync();
        var publishObj = JsonConvert.DeserializeObject<PublishResponse>(publishJson);

        return new InstagramPostPublishResult
        {
            PostId = publishObj.Id
        };
    }

    public async Task RefreshAccessTokenAsync(string accountId)
    {
        await _tokenService.RefreshAccessTokenAsync("Instagram", accountId);
    }

    private class InstagramInsightsRoot
    {
        [JsonProperty("data")]
        public List<InsightsData> Data { get; set; }
    }

    private class InsightsData
    {
        [JsonProperty("name")]
        public string Name { get; set; }
        [JsonProperty("values")]
        public List<InsightsValue> Values { get; set; }
    }

    private class InsightsValue
    {
        [JsonProperty("value")]
        public int Value { get; set; }
    }

    private class CreationMediaResponse
    {
        [JsonProperty("id")]
        public string Id { get; set; }
    }

    private class PublishResponse
    {
        [JsonProperty("id")]
        public string Id { get; set; }
    }
}

Extender a Otros Proveedores

Para otro proveedor, bastaría con:

  1. Crear otro servicio que herede de ExternalTokenServiceBase.
  2. Implementar RequestNewAccessTokenFromProviderAsync con la lógica específica del proveedor.
  3. Registrar ese servicio en el contenedor de dependencias.
  4. Consumir IExternalTokenService con el providerName adecuado (por ejemplo, "Google", "Twitter").

Conclusión

Con este enfoque:

  • Hemos aislado la lógica de tokens en un servicio genérico.
  • Podemos utilizar el mismo mecanismo para cualquier proveedor, sólo cambiando el providerName y el método de refresco.
  • La integración con Instagram (Meta) es ahora más limpia y reutilizable.
  • Añadir nuevos proveedores requerirá únicamente implementar su lógica de refresco de tokens.