Saltar al contenido

OAuth 2.0 y OpenID Connect: guía práctica

Implementa bien OAuth 2.0 y OpenID Connect: flujo de código con PKCE, gestión de tokens, rotación de refresh y los fallos que causan robo de cuentas.

4 min de lectura
Diagrama del flujo de código de autorización de OAuth 2.0 que muestra las interacciones entre el cliente, el servidor de autorización y el servidor de recursos

OAuth 2.0 es autorización, no autenticación

OAuth 2.0 responde a la pregunta «¿qué puede acceder esta aplicación?», no a «¿quién es este usuario?». OpenID Connect (OIDC) añade una capa de autenticación sobre OAuth y aporta tokens de identidad que indican quién es el usuario. Confundir ambos conceptos genera vulnerabilidades de seguridad, como usar un access token pensado para una API como si fuera prueba de identidad en otra.

Flujo de código de autorización con PKCE

El flujo de código de autorización con PKCE (Proof Key for Code Exchange) es el flujo recomendado para todo tipo de cliente: aplicaciones web, aplicaciones móviles y SPA. Evita los ataques de interceptación del código de autorización.

tstypescript
import { randomBytes, createHash } from "node:crypto";
 
// Step 1: Generate PKCE challenge
function generatePKCE(): { verifier: string; challenge: string } {
  const verifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 128);
 
  const challenge = createHash("sha256")
    .update(verifier)
    .digest("base64url");
 
  return { verifier, challenge };
}
 
// Step 2: Build authorization URL
function buildAuthorizationURL(config: OAuthConfig): {
  url: string;
  state: string;
  verifier: string;
} {
  const { verifier, challenge } = generatePKCE();
  const state = randomBytes(16).toString("hex");
 
  const params = new URLSearchParams({
    response_type: "code",
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: "openid profile email",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  });
 
  return {
    url: `${config.authorizationEndpoint}?${params}`,
    state,
    verifier,
  };
}
tstypescript
// ❌ Implicit flow — tokens exposed in URL fragment
// DEPRECATED: access_token appears in browser history and referrer headers
const badUrl = `${authEndpoint}?response_type=token&client_id=${clientId}`;
 
// ✅ Authorization Code + PKCE — tokens never in URLs
const { url, state, verifier } = buildAuthorizationURL(config);
// Store state and verifier in session, redirect user to url

Intercambio de tokens

Una vez que el usuario autoriza el acceso, el servidor de autorización redirige de vuelta con un código. Ese código se intercambia por tokens desde el servidor.

tstypescript
async function exchangeCodeForTokens(
  code: string,
  verifier: string,
  config: OAuthConfig
): Promise<TokenResponse> {
  const response = await fetch(config.tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: config.redirectUri,
      client_id: config.clientId,
      client_secret: config.clientSecret,
      code_verifier: verifier,
    }),
  });
 
  if (!response.ok) {
    const error = await response.json();
    throw new AuthError(
      `Token exchange failed: ${error.error_description ?? error.error}`
    );
  }
 
  const tokens: TokenResponse = await response.json();
 
  // Validate the ID token
  if (tokens.id_token) {
    await validateIdToken(tokens.id_token, config);
  }
 
  return tokens;
}
 
interface TokenResponse {
  access_token: string;
  token_type: "Bearer";
  expires_in: number;
  refresh_token?: string;
  id_token?: string;
  scope: string;
}

Validación del ID token

El ID token es un JWT que contiene las claims de identidad. Hay que validarlo a fondo: firma, emisor (issuer), audiencia (audience), expiración y nonce.

tstypescript
import { jwtVerify, createRemoteJWKSet } from "jose";
 
async function validateIdToken(
  idToken: string,
  config: OAuthConfig
): Promise<UserInfo> {
  const jwks = createRemoteJWKSet(
    new URL(config.jwksUri)
  );
 
  const { payload } = await jwtVerify(idToken, jwks, {
    issuer: config.issuer,
    audience: config.clientId,
    maxTokenAge: "5m",
    clockTolerance: "30s",
  });
 
  // Verify required claims exist
  if (!payload.sub) {
    throw new AuthError("ID token missing sub claim");
  }
 
  return {
    id: payload.sub,
    email: payload.email as string | undefined,
    name: payload.name as string | undefined,
    emailVerified: payload.email_verified as boolean | undefined,
  };
}
 
interface UserInfo {
  id: string;
  email?: string;
  name?: string;
  emailVerified?: boolean;
}

Rotación de refresh tokens

Los access tokens tienen una vida corta (minutos). Los refresh tokens permiten obtener nuevos access tokens sin que el usuario intervenga. Es fundamental rotar los refresh tokens en cada uso: cada refresh token es de un solo uso, y reutilizar uno antiguo invalida toda la sesión.

tstypescript
class TokenManager {
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
 
  constructor(
    private readonly config: OAuthConfig,
    private readonly storage: TokenStorage
  ) {}
 
  async refreshAccessToken(): Promise<string> {
    const refreshToken = await this.storage.getRefreshToken();
 
    if (!refreshToken) {
      throw new AuthError("No refresh token available — re-authentication required");
    }
 
    const response = await fetch(this.config.tokenEndpoint, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
      }),
    });
 
    if (!response.ok) {
      // Refresh token was revoked or expired
      await this.storage.clearTokens();
      throw new AuthError("Session expired — please log in again");
    }
 
    const tokens: TokenResponse = await response.json();
 
    // Store the NEW refresh token (rotation)
    await this.storage.setAccessToken(tokens.access_token, tokens.expires_in);
    if (tokens.refresh_token) {
      await this.storage.setRefreshToken(tokens.refresh_token);
    }
 
    this.scheduleRefresh(tokens.expires_in);
    return tokens.access_token;
  }
 
  private scheduleRefresh(expiresInSeconds: number): void {
    if (this.refreshTimer) clearTimeout(this.refreshTimer);
 
    // Refresh 60 seconds before expiration
    const refreshInMs = (expiresInSeconds - 60) * 1000;
    this.refreshTimer = setTimeout(
      () => this.refreshAccessToken(),
      Math.max(refreshInMs, 0)
    );
  }
}

Almacenamiento seguro de tokens

El lugar donde se almacenan los tokens importa. En aplicaciones web, cookies HttpOnly. En móvil, APIs de almacenamiento seguro. Nunca localStorage para tokens sensibles.

tstypescript
// ❌ localStorage — accessible to any JavaScript on the page (XSS risk)
localStorage.setItem("access_token", tokens.access_token);
 
// ✅ HttpOnly, Secure, SameSite cookies — inaccessible to JavaScript
function setTokenCookie(
  res: Response,
  name: string,
  value: string,
  maxAge: number
): void {
  res.setHeader("Set-Cookie", [
    `${name}=${value}`,
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
    `Max-Age=${maxAge}`,
    "Path=/",
  ].join("; "));
}
 
// Set tokens as HttpOnly cookies after exchange
function handleCallback(req: Request, res: Response): void {
  // ... exchange code for tokens ...
  setTokenCookie(res, "access_token", tokens.access_token, tokens.expires_in);
  if (tokens.refresh_token) {
    setTokenCookie(res, "refresh_token", tokens.refresh_token, 30 * 24 * 3600);
  }
  res.redirect("/dashboard");
}

Errores de seguridad más comunes

Estos errores aparecen con frecuencia en implementaciones de OAuth en producción y suelen derivar en el robo de cuentas.

tstypescript
interface SecurityChecklist {
  check: string;
  risk: string;
  mitigation: string;
}
 
const oauthChecklist: SecurityChecklist[] = [
  {
    check: "State parameter validation",
    risk: "CSRF attack — attacker forces victim to link their account",
    mitigation: "Generate random state, store in session, validate on callback",
  },
  {
    check: "PKCE on all flows",
    risk: "Authorization code interception on public clients",
    mitigation: "Always use S256 code challenge, even for confidential clients",
  },
  {
    check: "Redirect URI exact match",
    risk: "Open redirect allows token theft via crafted URLs",
    mitigation: "Register exact redirect URIs, never wildcard or partial match",
  },
  {
    check: "ID token audience validation",
    risk: "Token confusion — token from another app used as identity proof",
    mitigation: "Verify aud claim matches your client_id",
  },
  {
    check: "Refresh token rotation",
    risk: "Stolen refresh token grants indefinite access",
    mitigation: "Rotate on use, detect reuse as compromise signal",
  },
];

Puntos clave

Usa el flujo de código de autorización con PKCE para todos los clientes: web, móvil y SPA. Nunca uses el flujo Implicit; expone los tokens en las URL. Valida los ID tokens a fondo: firma, emisor, audiencia y expiración. Almacena los tokens en cookies HttpOnly para aplicaciones web, nunca en localStorage.

Rota los refresh tokens en cada uso para que un token robado solo sirva una vez. Valida el parámetro state en cada callback para prevenir ataques CSRF. Registra las redirect URI de forma exacta: una coincidencia parcial habilita ataques de open redirect. OAuth y OIDC son protocolos complejos con muchos detalles delicados, pero implementarlos correctamente no es negociable en ninguna aplicación que maneje la identidad de los usuarios.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX