StackPractices
intermediate Por Mathias Paulenko

Inicio de Sesión OAuth 2.0

Cómo implementar autenticación OAuth 2.0 con Google, GitHub y otros proveedores. Cubre el flujo Authorization Code con PKCE, validación de state y refresh tokens.

Visión General

OAuth 2.0 es el estándar de la industria para autorización delegada, definido en el RFC 6749. Permite que los usuarios inicien sesión con cuentas existentes (Google, GitHub, Microsoft) sin exponer sus contraseñas a tu aplicación. Acá tenés el flujo Authorization Code con PKCE en Python, JavaScript y Java, con validación de state y refresh de tokens.

Cuándo Usar

Usá este recurso cuando:

  • Querés ofrecer “Iniciar sesión con Google / GitHub” en tu plataforma. Como alternativa sin contraseña que se ahorra el viaje al proveedor, mirá los Magic Links.
  • Necesitás acceder a datos del usuario desde APIs de terceros en su nombre.
  • Querés reducir la fatiga de contraseñas y el riesgo de almacenar credenciales.
  • Estás construyendo un SaaS donde los clientes enterprise esperan SSO.

Solución

Python (Flask + Authlib)

import os
from flask import Flask, redirect, session, url_for
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
oauth = OAuth(app)

google = oauth.register(
    name="google",
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
    access_token_url="https://oauth2.googleapis.com/token",
    authorize_url="https://accounts.google.com/o/oauth2/auth",
    api_base_url="https://www.googleapis.com/oauth2/v1/",
    client_kwargs={"scope": "openid email profile"},
)

@app.route("/login")
def login():
    redirect_uri = url_for("callback", _external=True)
    # Authlib genera y guarda el `state` automáticamente
    return google.authorize_redirect(redirect_uri)

@app.route("/callback")
def callback():
    token = google.authorize_access_token()  # valida `state`, intercambia el code
    user = google.get("userinfo").json()
    session["user"] = user
    return redirect("/dashboard")

JavaScript (Express + Passport)

const express = require("express");
const session = require("express-session");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: "/auth/google/callback",
      state: true, // genera y valida el parámetro state por vos
      pkce: true,
    },
    (accessToken, refreshToken, profile, done) => {
      // Buscá o creá el usuario en tu DB acá
      return done(null, profile);
    }
  )
);

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => done(null, { id }));

const app = express();
// express-session es obligatorio — passport.session() lee de ahí
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: "lax" },
}));
app.use(passport.initialize());
app.use(passport.session());

app.get("/auth/google", passport.authenticate("google", { scope: ["profile", "email"] }));
app.get(
  "/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/dashboard")
);

Java (Spring Security + OAuth2 Client)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/dashboard", true)
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
            );
        return http.build();
    }
}

Spring Security maneja state y PKCE automáticamente cuando configurás el client registration en application.yml.

Explicación

El flujo Authorization Code corre en cuatro pasos:

Secuencia del flujo Authorization Code de OAuth 2.0. El navegador del usuario es redirigido al proveedor con client_id, redirect_uri, scope y state. El usuario da su consentimiento, el proveedor redirige de vuelta con un código de autorización, y el backend intercambia el código más el verificador PKCE por tokens de acceso e ID en el endpoint de tokens.
  1. Redirect: tu app manda al usuario a la URL de autorización del proveedor con client_id, redirect_uri, scope y un state aleatorio.
  2. Consentimiento: el usuario inicia sesión en el proveedor y aprueba los scopes pedidos.
  3. Callback: el proveedor redirige de vuelta con un code de autorización.
  4. Intercambio de token: tu backend cambia el code (más el code_verifier si PKCE está activo) por un access_token y un id_token.

PKCE agrega un verificador secreto para que un código interceptado sea inútil sin él — obligatorio para SPAs y mobile, y suficientemente barato para activarlo en todos lados. state ata el callback a la petición que lo inició; sin él, el endpoint de callback queda abierto a ataques de login CSRF. La Best Current Practice de seguridad de OAuth 2.0 (RFC 9700) cubre los dos y todo lo demás que de otro modo aprenderías a las malas.

Variantes

FlujoCaso de uso¿Client secret?¿PKCE?
Authorization CodeApps web server-sideSíRecomendado
Authorization Code + PKCESPAs, apps móvilesNoObligatorio
Client CredentialsMáquina a máquinaSíNo
Device CodeTVs, herramientas CLINoNo
Flujo de decisión para elegir un grant type de OAuth 2.0. Si no hay usuario involucrado, usar Client Credentials. Si hay usuario y el dispositivo puede abrir un navegador, usar Authorization Code con PKCE. Si el dispositivo no puede abrir un navegador, como una TV o herramienta CLI, usar Device Code.

El flujo Implicit está deprecado. Devolvía tokens en el fragmento de la URL donde cualquier script podía leerlos, y el RFC 9700 dice explícitamente que no lo uses.

Lo que Funciona

  • Activá PKCE aun en apps server-side. Es un flag y elimina toda la clase de ataques de intercepción de código.
  • Dejá que la librería valide state. Authlib, Passport (state: true) y Spring Security lo hacen solos; solo no los puentees con callbacks manuales.
  • Pedí scopes mínimos. openid email profile alcanza para el login; agregá más solo cuando una feature lo necesite.
  • Guardá los tokens del lado del servidor. Mantené access y refresh tokens fuera de localStorage; un payload de XSS no debería poder llevárselos.
  • Normalizá los perfiles de proveedor. Guardá provider + provider_user_id y mapeá email/nombre a una sola forma, así sumar GitHub después es trabajo de datos, no de auth.

Errores Comunes

  1. Saltearte la validación de state. Armar tu propio handler de callback y olvidarte del state abre la puerta al login CSRF.
  2. Tokens en localStorage. Cualquier script inyectado los puede leer. Cookies HttpOnly o sesiones server-side son el lugar seguro.
  3. redirect_uri que no coincide. Los proveedores comparan byte a byte: una barra final o un puerto distinto rompe el callback. Registrá la URI exacta.
  4. Pedir scopes amplios de entrada. Los usuarios abandonan pantallas de consentimiento que piden más de lo que el login necesita.
  5. Tratar OAuth como autenticación. OAuth es autorización; el id_token y el /userinfo vienen de la capa OpenID Connect encima. Confundir las dos lleva a aceptar tokens destinados a otro.

Checklist de Seguridad

Los esenciales específicos de OAuth, condensados del RFC 9700:

  • state generado por petición y validado en el callback
  • PKCE obligatorio; no dejes que el proveedor degrade el flujo
  • redirect_uri registrada y matcheada exacta, nunca por prefijo
  • Refresh tokens rotados en cada uso donde el proveedor lo soporte
  • Scopes verificados por endpoint: un token de read no debería alcanzar endpoints de write
  • Sin tokens en URLs, query params o fragments (se filtran por logs y Referer)
  • Tokens nunca en localStorage (XSS los roba)
  • HTTPS en todo el flujo, nada de mixed content
  • Refresh tokens invalidados al hacer logout
  • Flujo Implicit deshabilitado (está deprecado)

Solución de Problemas

  • invalid_state en el callback. El state del callback no coincide con el de la sesión, suele ser una cookie de sesión perdida (revisá SameSite) o el usuario empezó el login en una pestaña y terminó en otra.
  • redirect_uri_mismatch. La URI enviada no coincide byte a byte con la registrada. Compará protocolo, host, puerto y barra final.
  • La pantalla de consentimiento muestra access_denied. El usuario rechazó, o tu app no está verificada para un scope sensible. Compará la lista de scopes con los requisitos de verificación de Google.
  • El intercambio de token devuelve invalid_grant. El código expiró (viven ~10 minutos), ya se usó, o el redirect_uri del intercambio no coincide con el del paso authorize.
  • Loop de login. El callback funciona pero la cookie de sesión no se setea — revisá Secure, SameSite y si hay un proxy que está quitando el Set-Cookie.

Preguntas frecuentes

¿Puedo usar OAuth 2.0 para autenticación máquina a máquina?

Sí, con el flujo Client Credentials: el servicio se autentica con su propio client ID y secret, sin usuario de por medio. Para APIs internas más simples, las API keys pueden alcanzar.

¿Cómo soporto varios proveedores (Google, GitHub, Microsoft)?

Usá una librería que abstraiga las diferencias (Authlib, Passport, Spring Security). Guardá provider y provider_user_id en tu tabla de usuarios y normalizá email/nombre a una sola forma de perfil. Cada proveedor documenta sus propias particularidades — el flujo OAuth de GitHub, por ejemplo, difiere del de Google en formato de scopes y tipos de token. Para un walkthrough específico de GitHub en Node.js, mirá Node.js OAuth2 GitHub Login.

¿Cuál es la diferencia entre OAuth 2.0 y OpenID Connect?

OAuth 2.0 es un framework de autorización ("¿puede esta app acceder a mis datos?"). OpenID Connect es una capa de autenticación encima que estandariza los claims de identidad vía el id_token y el /userinfo. Si solo necesitás saber quién es el usuario, OIDC alcanza; si necesitás acceso a APIs en su nombre, hacen falta los scopes de OAuth también.

¿De verdad necesito PKCE para una app server-side?

El RFC 9700 lo recomienda para todos los clientes, confidenciales o no. Cuesta un flag y mata los ataques de intercepción de código aunque la respuesta de autorización se filtre por logs o extensiones del navegador. No hay buena razón para dejarlo apagado.

¿Dónde debería guardar los tokens?

Del lado del servidor, atados a la sesión del usuario. El localStorage del navegador y las cookies sin HttpOnly exponen los tokens a cualquier script que caiga en la página. Si el proveedor ofrece rotación de refresh tokens, activala — un token robado muere después del primer uso.

¿Por qué está deprecado el flujo Implicit?

Devolvía access tokens en el fragmento de la URL, donde extensiones del navegador, el historial y los logs podían capturarlos, y no podía usar PKCE ni refresh tokens. Authorization Code + PKCE lo reemplazó; mirá las notas de migración de oauth.net.

Código companion: El repo companion de oauth2-login tiene los tres ejemplos como apps completas ejecutables, más el application.yml de Spring para Google y GitHub.