Saltar al contenido

Los flujos de OAuth 2.0 sin misterios

Authorization Code, PKCE, Client Credentials — qué flujo de OAuth usar según el tipo de aplicación y qué fallos de seguridad se esconden en cada uno.

3 min de lectura
Diagrama de secuencia del flujo de Authorization Code de OAuth 2.0 entre el cliente, el servidor de autorización y el servidor de recursos

OAuth 2.0 es el estándar para la autorización delegada, pero su especificación es tan flexible que resulta confusa. La variedad de grant types, parámetros opcionales y extensiones propias de cada proveedor dificulta saber qué flujo es el correcto para tu aplicación. La mayoría de las vulnerabilidades de seguridad en implementaciones de OAuth surgen de elegir el flujo equivocado o de saltarse pasos de validación críticos.

Los cuatro flujos que importan

OAuth 2.0 define varios grant types. En la práctica, cuatro de ellos cubren todos los casos de uso modernos.

FlujoCaso de usoTipo de cliente¿Involucra al usuario?
Authorization Code + PKCEAplicaciones web, aplicaciones móviles, SPAPúblicoSí
Client CredentialsServicio a servicioConfidencialNo
Device AuthorizationSmart TVs, herramientas CLIPúblicoSí
Refresh TokenExtensión de la duración de la sesiónAmbosNo (tras la autenticación)

El flujo Implicit y el flujo Resource Owner Password están obsoletos. Si usas alguno de los dos, migra ahora.

Authorization Code con PKCE

Este es el flujo recomendado para cualquier aplicación en la que un usuario inicie sesión. PKCE (Proof Key for Code Exchange) evita los ataques de interceptación del código de autorización.

tstypescript
// Step 1: Generate PKCE challenge
import crypto from "crypto";
 
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto
    .createHash("sha256")
    .update(verifier)
    .digest("base64url");
 
  return { verifier, challenge };
}
 
// Step 2: Redirect user to authorization server
function getAuthorizationUrl(pkce: { challenge: string }) {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: process.env.OAUTH_CLIENT_ID!,
    redirect_uri: "https://myapp.com/callback",
    scope: "openid profile email",
    state: crypto.randomBytes(16).toString("hex"),
    code_challenge: pkce.challenge,
    code_challenge_method: "S256",
  });
 
  return `https://auth.provider.com/authorize?${params}`;
}
tstypescript
// Step 3: Exchange authorization code for tokens
async function exchangeCode(code: string, verifier: string) {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: "https://myapp.com/callback",
      client_id: process.env.OAUTH_CLIENT_ID!,
      code_verifier: verifier, // PKCE verification
    }),
  });
 
  return response.json();
  // { access_token: "...", refresh_token: "...", id_token: "...", expires_in: 3600 }
}

El parámetro state evita los ataques CSRF. El code_verifier demuestra que el mismo cliente que inició el flujo es el que lo está completando.

Flujo Client Credentials

Pensado para la comunicación servicio a servicio, donde no interviene ningún usuario. El cliente se autentica con sus propias credenciales.

tstypescript
// ✅ Service-to-service authentication
async function getServiceToken() {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`,
      ).toString("base64")}`,
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      scope: "api:read api:write",
    }),
  });
 
  return response.json();
}
 
// Cache the token until near expiry
let cachedToken: { token: string; expiresAt: number } | null = null;
 
async function getValidToken(): Promise<string> {
  if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
    return cachedToken.token;
  }
 
  const data = await getServiceToken();
  cachedToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
 
  return cachedToken.token;
}

Nunca expongas los client secrets en código frontend. Client Credentials es exclusivamente para aplicaciones del lado del servidor.

Errores de seguridad comunes

tstypescript
// ❌ Not validating the state parameter — CSRF vulnerability
app.get("/callback", async (req, res) => {
  const { code } = req.query;
  const tokens = await exchangeCode(code); // Missing state check!
});
 
// ✅ Always validate state
app.get("/callback", async (req, res) => {
  const { code, state } = req.query;
 
  const savedState = req.session.oauthState;
  if (!state || state !== savedState) {
    return res.status(403).json({ error: "Invalid state parameter" });
  }
 
  delete req.session.oauthState;
  const tokens = await exchangeCode(code, req.session.pkceVerifier);
});
tstypescript
// ❌ Not validating the ID token
const user = jwt.decode(tokens.id_token); // Decode without verify!
 
// ✅ Verify the ID token signature, issuer, and audience
const user = jwt.verify(tokens.id_token, publicKey, {
  issuer: "https://auth.provider.com",
  audience: process.env.OAUTH_CLIENT_ID,
  algorithms: ["RS256"],
});

Almacenamiento y ciclo de vida de los tokens

tstypescript
// Access token: short-lived, used for API calls
// Refresh token: long-lived, used to get new access tokens
 
async function refreshAccessToken(refreshToken: string) {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
    }),
  });
 
  if (!response.ok) {
    // Refresh token expired or revoked — user must re-authenticate
    throw new AuthenticationError("Session expired");
  }
 
  return response.json();
}
TokenDuraciónAlmacenamientoEstrategia de renovación
access token15-60 minutosMemoria o cookie HTTP-onlyUsar el refresh token
refresh token7-30 díasCookie HTTP-only o lado del servidor seguroReautenticar al expirar
ID tokenIgual que el access tokenMemoriaNo se renueva — se vuelve a obtener al renovar el access token

Puntos clave

  1. Usa Authorization Code + PKCE para cualquier aplicación de cara al usuario: web, móvil o SPA
  2. Client Credentials es solo para comunicación servicio a servicio — nunca expongas client secrets en el navegador
  3. Valida siempre el parámetro state — omitirlo abre una vulnerabilidad CSRF
  4. Verifica las firmas del ID token — decodificar sin verificar confía en datos sin firmar
  5. Los access tokens de corta duración junto con la rotación de refresh tokens limitan la ventana de daño de un token robado
  6. El flujo Implicit está obsoleto — migra a Authorization Code + PKCE
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX