Problema

En entornos donde Azure actúa como hub de integración (por ejemplo, conectando Salesforce o ServiceNow), los equipos suelen mezclar dos patrones de OAuth 2.0 sin entender sus implicaciones. El resultado típico es:

  • Un backend que usa Authorization Code para llamadas entre servicios, generando refresh tokens que expiran y no se actualizan correctamente.
  • Un servicio que usa Client Credentials pero intenta reutilizar un token de usuario, provocando errores de autorización.
  • La validación del JWT se hace sin revisar la firma ni los claims críticos, lo que abre la puerta a accesos no deseados.

El síntoma más frecuente es que la integración funciona en pruebas, pero falla silenciosamente después de unos días o semanas, con errores como 401 Unauthorized o invalid_grant sin una pista clara del origen.

Causa

1. Selección del flujo equivocado

  • Client Credentials está pensado para service‑to‑service sin interacción humana. No genera refresh token; el token se renueva mediante una nueva petición al endpoint de token.
  • Authorization Code incluye un refresh token que debe almacenarse y renovarse. Si el código se usa en un proceso sin UI (por ejemplo, un job programado), el refresh token puede quedar obsoleto.

2. Rotación de refresh token mal gestionada

Microsoft Entra ID implementa refresh‑token rotation: cada vez que se usa un refresh token, el servidor devuelve uno nuevo y revoca el anterior. Si la aplicación sigue usando el token antiguo (por ejemplo, por caché o por no actualizar la base de datos), la siguiente petición falla con invalid_grant.

3. Validación insuficiente del JWT

Un JWT está firmado, no cifrado. Si solo se decodifica el payload y se confía en él, cualquier atacante que modifique el token (sin la firma) será detectado, pero si la firma no se verifica, el payload puede ser aceptado sin garantía.

4. Almacenamiento inseguro de credenciales

Guardar client secret o refresh token en texto plano dentro de archivos de configuración o variables de entorno expone el secreto a procesos no autorizados y a incidentes de fuga.

Solución

A. Elegir el flujo correcto según el escenario

Escenario Flujo recomendado Por qué
Llamada backend → backend (por ejemplo, Azure Function → API Management) Client Credentials No hay usuario, el token se solicita con client_id y client_secret y se renueva cada vez que expira.
Integración que requiere acceso a recursos de usuario (por ejemplo, leer correos de un buzón) Authorization Code + PKCE Necesita consentimiento del usuario y un refresh token para mantener la sesión.
Aplicación híbrida (backend necesita actuar como usuario y también como servicio) Usa Client Credentials para operaciones de servicio y Authorization Code solo donde sea estrictamente necesario.

B. Implementar rotación segura de refresh token

  1. Almacenar el refresh token en una tabla con marca de tiempo.
  2. Al usar el token, actualizar inmediatamente la fila con el nuevo refresh token devuelto por el endpoint.
  3. Eliminar cualquier token antiguo que no haya sido reemplazado en los últimos 5 minutos (previene race conditions).
  4. Implementar lógica de reintento: si la llamada devuelve invalid_grant, leer el último refresh token almacenado y volver a intentar una sola vez.

C. Validar JWT de forma completa en ASP.NET Core

dotnet add package Microsoft.Identity.Web
dotnet add package Microsoft.IdentityModel.Logging
// Startup.cs o Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(options =>
    {
        builder.Configuration.Bind("AzureAd", options);
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidateIssuerSigningKey = true;
    },
    options => { builder.Configuration.Bind("AzureAd", options); });

app.UseAuthentication();
app.UseAuthorization();
  • Verifica iss, aud, exp y la firma contra la clave pública de Microsoft (/.well-known/openid-configuration).

D. Código de ejemplo: Client Credentials con MSAL

using System;
using System.Threading.Tasks;
using Microsoft.Identity.Client;

public class TokenProvider
{
    private readonly IConfidentialClientApplication _app;
    private readonly string[] _scopes = new[] { "https://graph.microsoft.com/.default" };

    public TokenProvider(string tenantId, string clientId, string clientSecret)
    {
        _app = ConfidentialClientApplicationBuilder.Create(clientId)
            .WithClientSecret(clientSecret)
            .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
            .Build();
    }

    public async Task<string> GetAccessTokenAsync()
    {
        var result = await _app.AcquireTokenForClient(_scopes).ExecuteAsync();
        return result.AccessToken;
    }
}

E. Código de ejemplo: Authorization Code con PKCE y rotación

using System;
using System.Threading.Tasks;
using Microsoft.Identity.Client;

public class InteractiveTokenProvider
{
    private readonly IPublicClientApplication _app;
    private readonly string[] _scopes = new[] { "User.Read" };
    private const string TokenCacheFile = "refresh_token.json";

    public InteractiveTokenProvider(string clientId, string tenantId)
    {
        _app = PublicClientApplicationBuilder.Create(clientId)
            .WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
            .WithRedirectUri("http://localhost")
            .Build();

        // Cargar refresh token persistido
        if (System.IO.File.Exists(TokenCacheFile))
        {
            var data = System.IO.File.ReadAllText(TokenCacheFile);
            _app.UserTokenCache.SetBeforeAccess(args => args.TokenCache.DeserializeMsalV3(Convert.FromBase64String(data)));
            _app.UserTokenCache.SetAfterAccess(args =>
            {
                if (args.HasStateChanged)
                {
                    var serialized = Convert.ToBase64String(args.TokenCache.SerializeMsalV3());
                    System.IO.File.WriteAllText(TokenCacheFile, serialized);
                }
            });
        }
    }

    public async Task<string> AcquireTokenAsync()
    {
        var accounts = await _app.GetAccountsAsync();
        try
        {
            var result = await _app.AcquireTokenSilent(_scopes, accounts.FirstOrDefault())
                .ExecuteAsync();
            return result.AccessToken;
        }
        catch (MsalUiRequiredException)
        {
            var result = await _app.AcquireTokenInteractive(_scopes)
                .WithPrompt(Prompt.SelectAccount)
                .ExecuteAsync();
            return result.AccessToken;
        }
    }
}

El bloque anterior persiste el refresh token en un archivo JSON y lo actualiza automáticamente cada vez que MSAL lo renueva, evitando la pérdida del token.

Cuándo aplicar esta solución

  • Síntomas de token expirado: llamadas que retornan 401 después de varios días sin cambios en el código.
  • Errores de invalid_grant en logs de integración con Salesforce/ServiceNow.
  • Necesidad de validar tokens en APIs expuestas a terceros (por ejemplo, microservicios internos).
  • Entornos de CI/CD donde los pipelines ejecutan tareas sin UI y requieren acceso a recursos de Azure.

No aplicar si la integración solo necesita un token de corta duración y se renueva en cada ejecución (por ejemplo, scripts de una sola ejecución que usan client credentials y no guardan refresh token). En ese caso, basta con solicitar un nuevo token cada vez.

Verificación

  1. Reproducir la falla: fuerza la expiración del refresh token (eliminar la fila de la base de datos) y observa que la siguiente llamada devuelve invalid_grant.
  2. Aplicar la lógica de actualización: ejecuta la aplicación con el código de rotación; verifica que la tabla contiene el nuevo refresh token después de la primera renovación.
  3. Validar JWT: usa una herramienta como jwt.ms para inspeccionar el token y confirma que la firma es válida y que los claims iss, aud y exp coinciden con la configuración.
  4. Prueba de carga: simula 50 peticiones concurrentes que usan el mismo refresh token; verifica que solo una de ellas actualiza el token y que las demás usan la versión recién guardada sin errores.

Notas adicionales

  • PKCE es obligatorio cuando se usa Authorization Code en aplicaciones públicas; evita que un atacante intercambie el código por un token.
  • En Azure, el endpoint de token para client credentials es https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token. No mezclarlo con el endpoint de autorización.
  • Si la aplicación se despliega en contenedores, monta un volumen persistente para el archivo de caché de refresh token; de lo contrario, cada reinicio perderá el token y provocará una ola de invalid_grant.
  • Monitorea la métrica token_acquisition_success y token_acquisition_failure en Application Insights; una subida repentina indica problemas de rotación o de credenciales caducadas.