Vicen Moreno

Pro Googler

Follow me on GitHub

OAuth 2.0 Security Best Practices 2024 - Lo que debes saber

Las recomendaciones de seguridad OAuth evolucionan. ¿Estás al día?

El estado actual

OAuth 2.0 tiene 12 años. Las mejores prácticas han cambiado significativamente.

RFC 9700 “OAuth 2.0 Security Best Current Practice” (2024) consolida todo lo aprendido.

Resumen de cambios importantes

Deprecated (no usar)

  • ❌ Implicit flow
  • ❌ Resource Owner Password Credentials (ROPC)
  • ❌ Tokens en URL fragments
  • ❌ Bearer tokens sin binding

Obligatorio

  • ✅ PKCE para todos los clientes
  • ✅ Exact redirect URI matching
  • ✅ Sender-constrained tokens (cuando posible)
  • ✅ Refresh token rotation

1. PKCE siempre (no solo para públicos)

Antes

PKCE solo para clientes públicos (móviles, SPAs)
Clientes confidenciales: solo client_secret

Ahora

PKCE para TODOS los clientes, incluso con client_secret

Razón: Previene code injection attacks incluso en clientes confidenciales.

// Servidor: Forzar PKCE
new Client
{
    ClientId = "my-confidential-client",
    ClientSecrets = { new Secret("secret".Sha256()) },
    RequirePkce = true, // Siempre true
    AllowedGrantTypes = GrantTypes.Code
}
// Cliente: Siempre enviar PKCE
const codeVerifier = generateRandomString(64);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

// Authorization request
const authUrl = `${authServer}/authorize?
  client_id=${clientId}&
  response_type=code&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256&
  redirect_uri=${redirectUri}`;

2. Exact redirect URI matching

Antes (peligroso)

Registrado: https://app.ejemplo.com/callback
Aceptado: https://app.ejemplo.com/callback?evil=param ❌
Aceptado: https://app.ejemplo.com/callback/../other ❌

Ahora (correcto)

Registrado: https://app.ejemplo.com/callback
Solo acepta: https://app.ejemplo.com/callback exactamente
// Configuración correcta
new Client
{
    RedirectUris = {
        "https://app.ejemplo.com/callback"  // Exacto, sin wildcards
    },
    // NO usar: "https://app.ejemplo.com/*" ❌
}

3. No más Implicit flow

Antes

SPAs usaban: response_type=token
Token en URL fragment: #access_token=xxx

Ahora

SPAs deben usar: response_type=code + PKCE
Token via POST a token endpoint

Razones:

  • Tokens en URL son visibles en logs, history, referrer
  • No hay refresh tokens en implicit
  • No hay forma de verificar audience
// SPA correcto (Authorization Code + PKCE)
async function login() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // Guardar verifier para después
  sessionStorage.setItem('code_verifier', codeVerifier);

  // Redirect a authorize
  window.location.href = `${authServer}/authorize?
    client_id=${clientId}&
    response_type=code&
    code_challenge=${codeChallenge}&
    code_challenge_method=S256&
    redirect_uri=${redirectUri}&
    scope=openid profile`;
}

// Callback
async function handleCallback() {
  const code = new URLSearchParams(window.location.search).get('code');
  const codeVerifier = sessionStorage.getItem('code_verifier');

  // Intercambiar code por token via POST
  const response = await fetch(`${authServer}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      code_verifier: codeVerifier,
      client_id: clientId,
      redirect_uri: redirectUri
    })
  });

  const tokens = await response.json();
  // Token nunca estuvo en URL
}

4. Refresh token rotation

Cada vez que usas un refresh token, recibes uno nuevo y el anterior se invalida.

// Servidor
new Client
{
    RefreshTokenUsage = TokenUsage.OneTimeOnly,
    RefreshTokenExpiration = TokenExpiration.Sliding,
    SlidingRefreshTokenLifetime = 1296000, // 15 días
    AbsoluteRefreshTokenLifetime = 2592000 // 30 días máximo
}

Detección de robo

Si un refresh token robado se usa después del legítimo:

1. Usuario legítimo usa refresh_token_1 → recibe refresh_token_2
2. Atacante intenta usar refresh_token_1 (ya invalidado)
3. Servidor detecta reuso de token inválido
4. Servidor revoca TODOS los tokens de esa sesión
5. Usuario debe re-autenticarse
// Implementación de detección
public async Task<TokenResponse> RefreshTokenAsync(string refreshToken)
{
    var storedToken = await _tokenStore.GetAsync(refreshToken);

    if (storedToken == null)
    {
        // Token no existe - posible replay attack
        // Revocar toda la familia de tokens
        await _tokenStore.RevokeTokenFamilyAsync(storedToken.FamilyId);
        throw new SecurityException("Refresh token reuse detected");
    }

    // Emitir nuevo token
    var newToken = GenerateRefreshToken();
    await _tokenStore.InvalidateAsync(refreshToken);
    await _tokenStore.SaveAsync(newToken, storedToken.FamilyId);

    return new TokenResponse { RefreshToken = newToken };
}

5. Sender-constrained tokens (DPoP)

Access tokens tradicionales: quien tenga el token puede usarlo.

DPoP (Demonstrating Proof of Possession): token vinculado a clave del cliente.

// Cliente genera par de claves
const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  true,
  ['sign', 'verify']
);

// Crear DPoP proof
function createDPoPProof(httpMethod: string, httpUri: string): string {
  const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: publicKeyJwk };
  const payload = {
    jti: generateUUID(),
    htm: httpMethod,
    htu: httpUri,
    iat: Math.floor(Date.now() / 1000)
  };

  return sign(header, payload, privateKey);
}

// Token request con DPoP
const response = await fetch(`${authServer}/token`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'DPoP': createDPoPProof('POST', `${authServer}/token`)
  },
  body: tokenRequestBody
});

// API request con DPoP
const apiResponse = await fetch('https://api.ejemplo.com/data', {
  headers: {
    'Authorization': `DPoP ${accessToken}`,
    'DPoP': createDPoPProof('GET', 'https://api.ejemplo.com/data')
  }
});

6. Pushed Authorization Requests (PAR)

Authorization parameters no van en URL, van en POST previo.

// Paso 1: Push authorization request
const parResponse = await fetch(`${authServer}/par`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: 'code',
    scope: 'openid profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  })
});

const { request_uri } = await parResponse.json();
// request_uri: "urn:ietf:params:oauth:request_uri:abc123"

// Paso 2: Authorize con solo request_uri
window.location.href = `${authServer}/authorize?
  client_id=${clientId}&
  request_uri=${request_uri}`;

Beneficios:

  • Parámetros no expuestos en URL
  • Previene manipulación de parámetros
  • Mejor auditoría

7. Token storage en SPAs

Evitar

  • ❌ localStorage (XSS vulnerable)
  • ❌ URL parameters
  • ❌ Cookies accesibles por JS

Opciones seguras

Opción 1: Backend for Frontend (BFF)

SPA → BFF (cookie httpOnly) → API

BFF maneja tokens, SPA solo tiene cookie de sesión.

Opción 2: Service Worker

// Tokens almacenados en Service Worker
// No accesibles desde JavaScript principal
self.addEventListener('fetch', (event) => {
  if (needsAuth(event.request.url)) {
    const token = getStoredToken(); // En SW memory
    event.respondWith(
      fetch(event.request, {
        headers: {
          ...event.request.headers,
          'Authorization': `Bearer ${token}`
        }
      })
    );
  }
});

Opción 3: Memory only (refresh frecuente)

// Token solo en memoria, refresh silencioso frecuente
let accessToken: string | null = null;

async function getAccessToken(): Promise<string> {
  if (!accessToken || isExpired(accessToken)) {
    accessToken = await silentRefresh();
  }
  return accessToken;
}

8. Validation checklist

Access token validation (Resource Server)

public async Task<ClaimsPrincipal> ValidateTokenAsync(string token)
{
    var validationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = "https://auth.ejemplo.com",

        ValidateAudience = true,
        ValidAudience = "my-api",

        ValidateLifetime = true,
        ClockSkew = TimeSpan.FromMinutes(1),

        ValidateIssuerSigningKey = true,
        IssuerSigningKeys = await GetSigningKeysAsync(),

        // Para DPoP
        ValidateTokenReplay = true
    };

    var handler = new JwtSecurityTokenHandler();
    return handler.ValidateToken(token, validationParameters, out _);
}

ID token validation (Client)

Verificar:

  1. Signature
  2. iss (issuer)
  3. aud (audience = client_id)
  4. exp (expiration)
  5. iat (issued at, no muy viejo)
  6. nonce (si se envió)
  7. at_hash (si access token presente)

Resumen de recomendaciones 2024

Práctica Estado
PKCE para todos ✅ Obligatorio
Implicit flow ❌ Deprecated
ROPC ❌ Deprecated
Exact redirect URI ✅ Obligatorio
Refresh token rotation ✅ Recomendado
DPoP ✅ Recomendado (cuando posible)
PAR ✅ Recomendado (alto riesgo)
BFF para SPAs ✅ Recomendado

Recursos

  • RFC 9700: OAuth 2.0 Security BCP
  • RFC 9449: DPoP
  • RFC 9126: PAR
  • OAuth 2.1 (draft)

¿Tu implementación OAuth sigue estas prácticas? ¿Qué cambios necesitas hacer?


 Anterior

Por Vicente José Moreno Escobar el 20 de noviembre de 2024
Archivado en: OAuth2   Seguridad   Buenas Prácticas



Puedes disfrutar de otros artículos como éste en el archivo del sitio.