TechLead
Intermedio
30 min
Lección 6 de 10
API

Autenticación de APIs

Implementa autenticación segura de APIs con claves API, tokens JWT y OAuth 2.0

¿Por qué Autenticación de APIs?

La Autenticación de APIs verifica la identidad de los clientes que realizan solicitudes a tu API. Asegura que solo usuarios y aplicaciones autorizadas puedan acceder a recursos protegidos, previniendo acceso no autorizado y violaciones de datos.

Diferentes métodos de autenticación se adaptan a diferentes casos de uso—desde claves API simples para comunicación servidor a servidor hasta OAuth 2.0 para autorización de aplicaciones de terceros.

🔐 Autenticación vs Autorización

Autenticación (AuthN)

"¿Quién eres?"

Verificar identidad con credenciales como contraseñas, tokens o certificados.

Autorización (AuthZ)

"¿Qué puedes hacer?"

Determinar qué recursos/acciones puede acceder un usuario verificado.

1. Claves API

La forma más simple de autenticación—una clave secreta enviada con cada solicitud:

// Autenticación con Clave API
// Usualmente enviada en headers o parámetros de consulta

// Método 1: Header (preferido)
const response = await fetch('https://api.example.com/data', {
  headers: {
    'X-API-Key': 'tu-clave-api-aqui'
  }
});

// Método 2: Parámetro de consulta (menos seguro - visible en logs)
const response = await fetch(
  'https://api.example.com/data?api_key=tu-clave-api-aqui'
);

// Método 3: Basic Auth con clave API como usuario
const credentials = btoa('tu-clave-api:'); // Codificar en Base64
const response = await fetch('https://api.example.com/data', {
  headers: {
    'Authorization': `Basic ${credentials}`
  }
});

// Cliente API reutilizable con clave API
class ApiKeyClient {
  constructor(baseUrl, apiKey) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
  }

  async request(endpoint, options = {}) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        'X-API-Key': this.apiKey,
        'Content-Type': 'application/json',
        ...options.headers
      }
    });
    return response.json();
  }
}

// Uso
const api = new ApiKeyClient('https://api.weather.com', 'abc123');
const weather = await api.request('/current?city=London');

⚠️ Seguridad de Claves API

  • • Nunca expongas claves API en código del lado del cliente
  • • Usa variables de entorno para almacenamiento
  • • Rota las claves regularmente
  • • Usa claves diferentes para diferentes entornos

2. JWT (JSON Web Tokens)

Tokens autocontenidos que codifican información del usuario y están firmados criptográficamente:

// Estructura JWT: header.payload.signature
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
// eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.
// SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// Flujo de Autenticación JWT
class AuthClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.accessToken = null;
    this.refreshToken = null;
  }

  // Paso 1: Iniciar sesión para obtener tokens
  async login(email, password) {
    const response = await fetch(`${this.baseUrl}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });

    if (!response.ok) {
      throw new Error('Error al iniciar sesión');
    }

    const data = await response.json();
    this.accessToken = data.accessToken;
    this.refreshToken = data.refreshToken;
    
    // Almacenar token de refresco de forma segura
    localStorage.setItem('refreshToken', this.refreshToken);
    
    return data.user;
  }

  // Paso 2: Usar token de acceso para solicitudes API
  async request(endpoint, options = {}) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        'Authorization': `Bearer ${this.accessToken}`,
        'Content-Type': 'application/json',
        ...options.headers
      }
    });

    // Si el token expiró, refrescar y reintentar
    if (response.status === 401) {
      await this.refreshAccessToken();
      return this.request(endpoint, options);
    }

    return response.json();
  }

  // Paso 3: Refrescar token de acceso expirado
  async refreshAccessToken() {
    const response = await fetch(`${this.baseUrl}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: this.refreshToken })
    });

    if (!response.ok) {
      // Token de refresco también expiró - redirigir a login
      this.logout();
      throw new Error('Sesión expirada');
    }

    const data = await response.json();
    this.accessToken = data.accessToken;
  }

  // Paso 4: Cerrar sesión
  logout() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('refreshToken');
    window.location.href = '/login';
  }
}

// Uso
const auth = new AuthClient('https://api.example.com');
await auth.login('usuario@example.com', 'password123');

const profile = await auth.request('/user/profile');
const posts = await auth.request('/user/posts');

Decodificando Tokens JWT

// Los tokens JWT pueden decodificarse (pero no verificarse) del lado del cliente
// ¡Nunca confíes en datos decodificados para decisiones de seguridad!

function decodeJWT(token) {
  try {
    const [header, payload, signature] = token.split('.');
    
    // Decodificar el payload en Base64
    const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
    return JSON.parse(decodedPayload);
  } catch {
    return null;
  }
}

// Verificar si el token expiró
function isTokenExpired(token) {
  const payload = decodeJWT(token);
  if (!payload || !payload.exp) return true;
  
  // exp está en segundos, Date.now() está en milisegundos
  return Date.now() >= payload.exp * 1000;
}

// Obtener tiempo hasta la expiración
function getTokenExpiresIn(token) {
  const payload = decodeJWT(token);
  if (!payload || !payload.exp) return 0;
  
  return Math.max(0, payload.exp * 1000 - Date.now());
}

// Uso
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

const payload = decodeJWT(token);
console.log('ID de Usuario:', payload.sub);
console.log('Expira:', new Date(payload.exp * 1000));
console.log('¿Está expirado?:', isTokenExpired(token));
console.log('Expira en:', getTokenExpiresIn(token) / 1000, 'segundos');

// Ejemplo de payload JWT
{
  "sub": "user-123",        // Sujeto (ID de usuario)
  "name": "Juan Pérez",     // Claim personalizado
  "email": "juan@test.com", // Claim personalizado
  "role": "admin",          // Claim personalizado
  "iat": 1704067200,        // Emitido en
  "exp": 1704070800         // Expira en
}

3. OAuth 2.0

Protocolo estándar de la industria para autorización, permitiendo que aplicaciones de terceros accedan a datos del usuario:

Tipos de Flujo OAuth 2.0

  • Código de Autorización - Para aplicaciones del lado del servidor (más seguro)
  • PKCE - Para SPAs y aplicaciones móviles
  • Credenciales de Cliente - Para máquina a máquina
  • Implícito - Obsoleto, no usar
// OAuth 2.0 con PKCE (Proof Key for Code Exchange)
// Mejor para aplicaciones basadas en navegador

class OAuthClient {
  constructor(config) {
    this.clientId = config.clientId;
    this.redirectUri = config.redirectUri;
    this.authorizationUrl = config.authorizationUrl;
    this.tokenUrl = config.tokenUrl;
    this.scope = config.scope;
  }

  // Paso 1: Generar desafío PKCE
  async generatePKCE() {
    // Generar verificador aleatorio
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    const verifier = btoa(String.fromCharCode(...array))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');

    // Crear desafío SHA-256
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');

    return { verifier, challenge };
  }

  // Paso 2: Redirigir a autorización
  async startAuth() {
    const { verifier, challenge } = await this.generatePKCE();
    const state = crypto.randomUUID();

    // Almacenar para verificación posterior
    sessionStorage.setItem('pkce_verifier', verifier);
    sessionStorage.setItem('oauth_state', state);

    // Construir URL de autorización
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      scope: this.scope,
      state: state,
      code_challenge: challenge,
      code_challenge_method: 'S256'
    });

    // Redirigir al servidor de autenticación
    window.location.href = `${this.authorizationUrl}?${params}`;
  }

  // Paso 3: Manejar callback e intercambiar código por tokens
  async handleCallback() {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');
    const error = params.get('error');

    // Verificar errores
    if (error) {
      throw new Error(params.get('error_description') || error);
    }

    // Verificar state
    const savedState = sessionStorage.getItem('oauth_state');
    if (state !== savedState) {
      throw new Error('Parámetro state inválido');
    }

    // Obtener verificador
    const verifier = sessionStorage.getItem('pkce_verifier');

    // Intercambiar código por tokens
    const response = await fetch(this.tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: this.clientId,
        redirect_uri: this.redirectUri,
        code: code,
        code_verifier: verifier
      })
    });

    if (!response.ok) {
      throw new Error('Intercambio de token falló');
    }

    const tokens = await response.json();
    
    // Limpiar
    sessionStorage.removeItem('pkce_verifier');
    sessionStorage.removeItem('oauth_state');

    return tokens;
  }
}

// Uso - ejemplo de Google OAuth
const oauth = new OAuthClient({
  clientId: 'tu-client-id.apps.googleusercontent.com',
  redirectUri: 'https://tuapp.com/callback',
  authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenUrl: 'https://oauth2.googleapis.com/token',
  scope: 'openid email profile'
});

// Iniciar login
document.getElementById('login').onclick = () => oauth.startAuth();

// En la página de callback
if (window.location.pathname === '/callback') {
  const tokens = await oauth.handleCallback();
  console.log('Token de acceso:', tokens.access_token);
}

Ejemplos de Login Social

// Usando OAuth con proveedores populares

// OAuth de GitHub
const githubAuth = new OAuthClient({
  clientId: 'tu-github-client-id',
  redirectUri: 'https://tuapp.com/auth/github/callback',
  authorizationUrl: 'https://github.com/login/oauth/authorize',
  tokenUrl: 'https://github.com/login/oauth/access_token',
  scope: 'user:email read:user'
});

// Después de obtener el token de acceso, obtener info del usuario
async function getGitHubUser(accessToken) {
  const response = await fetch('https://api.github.com/user', {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/json'
    }
  });
  return response.json();
}

// OAuth de Google
const googleAuth = new OAuthClient({
  clientId: 'tu-google-client-id',
  redirectUri: 'https://tuapp.com/auth/google/callback',
  authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenUrl: 'https://oauth2.googleapis.com/token',
  scope: 'openid email profile'
});

// OAuth de Microsoft
const microsoftAuth = new OAuthClient({
  clientId: 'tu-azure-client-id',
  redirectUri: 'https://tuapp.com/auth/microsoft/callback',
  authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
  tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  scope: 'openid email profile User.Read'
});

Almacenamiento Seguro de Tokens

// Mejores prácticas de almacenamiento de tokens

// ❌ NO almacenar en localStorage (vulnerable a XSS)
localStorage.setItem('accessToken', token);

// ✅ Para tokens de acceso: Mantener solo en memoria
class TokenManager {
  #accessToken = null;  // Campo privado
  
  setAccessToken(token) {
    this.#accessToken = token;
  }
  
  getAccessToken() {
    return this.#accessToken;
  }
  
  clearTokens() {
    this.#accessToken = null;
  }
}

// ✅ Para tokens de refresco: Usar cookies httpOnly (establecidas por el servidor)
// Respuesta del servidor establece cookie:
// Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict

// ✅ Si debes usar almacenamiento, encripta datos sensibles
class SecureStorage {
  constructor(encryptionKey) {
    this.key = encryptionKey;
  }

  async encrypt(data) {
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(JSON.stringify(data));
    
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      this.key,
      dataBuffer
    );

    return btoa(JSON.stringify({
      iv: Array.from(iv),
      data: Array.from(new Uint8Array(encrypted))
    }));
  }

  async decrypt(encryptedString) {
    const { iv, data } = JSON.parse(atob(encryptedString));
    
    const decrypted = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv: new Uint8Array(iv) },
      this.key,
      new Uint8Array(data)
    );

    const decoder = new TextDecoder();
    return JSON.parse(decoder.decode(decrypted));
  }
}

// Tiempo de espera de sesión - logout automático
function setupSessionTimeout(timeoutMinutes = 30) {
  let timeoutId;
  
  function resetTimer() {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      auth.logout();
      alert('Sesión expirada. Por favor inicia sesión de nuevo.');
    }, timeoutMinutes * 60 * 1000);
  }

  // Reiniciar con actividad del usuario
  ['click', 'keydown', 'mousemove', 'scroll'].forEach(event => {
    document.addEventListener(event, resetTimer);
  });

  resetTimer();
}

💡 Mejores Prácticas de Autenticación

  • Usa HTTPS siempre - Nunca envíes tokens sobre HTTP
  • Tokens de acceso de corta duración - 15-60 minutos máximo
  • Refrescar tokens de forma segura - Cookies HttpOnly preferidas
  • Implementar rotación de tokens - Nuevo token de refresco en cada uso
  • Usar PKCE para SPAs - Protege contra intercepción de código
  • Validar parámetro state - Previene ataques CSRF