Back to 0fee
0fee

Autenticacion OAuth: Google, GitHub, Microsoft y Apple

Como implementamos OAuth con Google, GitHub, Microsoft y Apple en 0fee.dev, incluyendo el flujo popup de Apple. Por Juste A. Gnimavo.

Thales & Claude | March 30, 2026 11 min 0fee
EN/ FR/ ES
oauthauthenticationgooglegithubmicrosoftapple

Una plataforma de pagos que pide a los desarrolladores crear otro usuario y contrasena mas empieza con el pie izquierdo. Los desarrolladores ya tienen cuentas con Google, GitHub, Microsoft y Apple. Esperan hacer clic en un boton y estar dentro.

Implementamos cuatro proveedores OAuth para 0fee.dev. Tres siguen el flujo estandar de redireccion. Uno -- Apple -- utiliza un flujo popup a traves de su SDK de JavaScript y requiere un manejo especial que nos costo mas tiempo que los otros tres combinados.

La arquitectura

Los cuatro proveedores OAuth comparten el mismo patron fundamental:

  1. El usuario hace clic en "Iniciar sesion con X"
  2. El usuario es redirigido a la pantalla de consentimiento del proveedor
  3. El proveedor redirige de vuelta con un codigo de autorizacion
  4. Nuestro backend intercambia el codigo por un token de acceso
  5. Obtenemos el perfil del usuario (correo, nombre, avatar)
  6. Creamos o vinculamos la cuenta de usuario
  7. Emitimos un token JWT de 0fee

Las diferencias estan en los detalles. Cada proveedor tiene su propio formato de URL de consentimiento, endpoint de intercambio de token, endpoint de perfil y peculiaridades.

python# Columnas de base de datos para vinculacion de identidad OAuth
class User(Base):
    __tablename__ = "users"

    id = Column(String, primary_key=True)
    email = Column(String, unique=True, nullable=True)
    name = Column(String, nullable=True)

    # IDs de proveedores OAuth
    google_id = Column(String, unique=True, nullable=True)
    github_id = Column(String, unique=True, nullable=True)
    microsoft_id = Column(String, unique=True, nullable=True)
    apple_id = Column(String, unique=True, nullable=True)

    # Metadatos de autenticacion
    password_hash = Column(String, nullable=True)  # Null para usuarios solo OAuth
    created_at = Column(DateTime, server_default=func.now())
    updated_at = Column(DateTime, onupdate=func.now())

Cada proveedor OAuth obtiene su propia columna: google_id, github_id, microsoft_id, apple_id. Un solo usuario puede vincular multiples proveedores. Si alguien inicia sesion con Google y luego inicia sesion con GitHub usando el mismo correo, vinculamos ambos IDs de proveedor a la misma cuenta.

Google OAuth: el mas sencillo

Google OAuth 2.0 es la implementacion OAuth mas documentada y predecible. El flujo funciona exactamente como describe la documentacion, sin sorpresas:

python# routes/auth/google.py
from httpx import AsyncClient

GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"

@router.get("/auth/google")
async def google_login():
    params = {
        "client_id": GOOGLE_CLIENT_ID,
        "redirect_uri": f"{BASE_URL}/auth/google/callback",
        "response_type": "code",
        "scope": "openid email profile",
        "access_type": "offline",
        "prompt": "consent",
    }
    url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
    return RedirectResponse(url)

@router.get("/auth/google/callback") async def google_callback(code: str): async with AsyncClient() as client: # Intercambiar codigo por tokens token_response = await client.post(GOOGLE_TOKEN_URL, data={ "code": code, "client_id": GOOGLE_CLIENT_ID, "client_secret": GOOGLE_CLIENT_SECRET, "redirect_uri": f"{BASE_URL}/auth/google/callback", "grant_type": "authorization_code", }) tokens = token_response.json() BLANK # Obtener perfil del usuario user_response = await client.get( GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {tokens['access_token']}"} ) profile = user_response.json() BLANK # Crear o vincular usuario user = await find_or_create_user( provider="google", provider_id=profile["id"], email=profile.get("email"), name=profile.get("name"), ) BLANK # Emitir JWT y redirigir al panel de control token = create_access_token(user.id) return RedirectResponse(f"/get-started?token={token}") ```

Google proporciona el correo directamente en la respuesta de userinfo, y el scope openid asegura que obtenemos un ID de usuario estable.

GitHub OAuth: el mas amigable para desarrolladores

El OAuth de GitHub es casi identico al de Google, con una particularidad: el endpoint de correo esta separado del endpoint de perfil de usuario. Los usuarios de GitHub pueden tener correos privados, por lo que necesitas una llamada API adicional:

python# routes/auth/github.py
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_USER_URL = "https://api.github.com/user"
GITHUB_EMAILS_URL = "https://api.github.com/user/emails"

@router.get("/auth/github/callback")
async def github_callback(code: str):
    async with AsyncClient() as client:
        # Intercambiar codigo por token
        token_response = await client.post(
            GITHUB_TOKEN_URL,
            data={
                "code": code,
                "client_id": GITHUB_CLIENT_ID,
                "client_secret": GITHUB_CLIENT_SECRET,
            },
            headers={"Accept": "application/json"}
        )
        access_token = token_response.json()["access_token"]

        # Obtener perfil del usuario
        headers = {"Authorization": f"Bearer {access_token}"}
        user_response = await client.get(GITHUB_USER_URL, headers=headers)
        profile = user_response.json()

        # Obtener correo principal (puede ser privado)
        email_response = await client.get(GITHUB_EMAILS_URL, headers=headers)
        emails = email_response.json()
        primary_email = next(
            (e["email"] for e in emails if e["primary"] and e["verified"]),
            None
        )

    user = await find_or_create_user(
        provider="github",
        provider_id=str(profile["id"]),
        email=primary_email,
        name=profile.get("name") or profile.get("login"),
    )

    token = create_access_token(user.id)
    return RedirectResponse(f"/get-started?token={token}")

Observa el respaldo para el campo de nombre: profile.get("name") or profile.get("login"). Muchos usuarios de GitHub no configuran un nombre para mostrar, asi que recurrimos a su nombre de usuario.

Microsoft OAuth: Azure AD multi-inquilino

Microsoft OAuth usa Azure Active Directory, y la configuracion multi-inquilino fue la decision clave. Con una app de un solo inquilino, solo los usuarios de una organizacion de Azure AD pueden iniciar sesion. Con multi-inquilino, cualquier cuenta de Microsoft funciona -- personal, laboral o educativa:

python# routes/auth/microsoft.py
# Multi-inquilino: "common" permite cualquier cuenta de Microsoft
MICROSOFT_AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
MICROSOFT_GRAPH_URL = "https://graph.microsoft.com/v1.0/me"

@router.get("/auth/microsoft")
async def microsoft_login():
    params = {
        "client_id": MICROSOFT_CLIENT_ID,
        "redirect_uri": f"{BASE_URL}/auth/microsoft/callback",
        "response_type": "code",
        "scope": "openid email profile User.Read",
        "response_mode": "query",
    }
    url = f"{MICROSOFT_AUTH_URL}?{urlencode(params)}"
    return RedirectResponse(url)

@router.get("/auth/microsoft/callback") async def microsoft_callback(code: str): async with AsyncClient() as client: token_response = await client.post(MICROSOFT_TOKEN_URL, data={ "code": code, "client_id": MICROSOFT_CLIENT_ID, "client_secret": MICROSOFT_CLIENT_SECRET, "redirect_uri": f"{BASE_URL}/auth/microsoft/callback", "grant_type": "authorization_code", "scope": "openid email profile User.Read", }) tokens = token_response.json() BLANK user_response = await client.get( MICROSOFT_GRAPH_URL, headers={"Authorization": f"Bearer {tokens['access_token']}"} ) profile = user_response.json() BLANK user = await find_or_create_user( provider="microsoft", provider_id=profile["id"], email=profile.get("mail") or profile.get("userPrincipalName"), name=profile.get("displayName"), ) BLANK token = create_access_token(user.id) return RedirectResponse(f"/get-started?token={token}") ```

El scope User.Read es esencial para el acceso a Microsoft Graph API. Sin el, el endpoint /me devuelve un 403. Tambien observa el respaldo para el correo: profile.get("mail") or profile.get("userPrincipalName"). Las cuentas personales de Microsoft tienen mail, pero las cuentas organizacionales a veces solo tienen userPrincipalName.

El inquilino /common en la URL es lo que hace esto multi-inquilino. Usar /consumers restringiria a cuentas personales, /organizations restringiria a cuentas laborales/educativas.

Apple OAuth: el atipico

Apple Sign In es diferente de los otros tres proveedores de maneras fundamentales:

  1. Flujo popup en lugar de flujo de redireccion. El SDK JS de Apple abre una ventana popup para la autenticacion en lugar de redirigir la pagina actual.
  2. Callback POST en lugar de GET. Apple envia el codigo de autorizacion a traves de una solicitud POST a la URL de callback, no una redireccion GET con parametros de consulta.
  3. El token de identidad es un JWT. En lugar de un endpoint de perfil separado, Apple incrusta informacion del usuario en un token de identidad JWT.
  4. La informacion del usuario se envia solo en el primer inicio de sesion. Apple envia el nombre y correo del usuario en el cuerpo POST solo la primera vez que autorizan tu app. En inicios de sesion posteriores, solo obtienes el token de identidad.
html<!-- Frontend: boton Apple Sign In con SDK JS -->
<script
    type="text/javascript"
    src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
></script>

<script>
    AppleID.auth.init({
        clientId: '${APPLE_CLIENT_ID}',
        scope: 'name email',
        redirectURI: '${BASE_URL}/auth/apple/callback',
        usePopup: true,
    });

    document.getElementById('apple-sign-in').addEventListener('click', async () => {
        try {
            const response = await AppleID.auth.signIn();
            // Enviar a nuestro backend
            const result = await fetch('/api/auth/apple/callback', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    code: response.authorization.code,
                    id_token: response.authorization.id_token,
                    user: response.user,  // Solo presente en el primer inicio de sesion
                }),
            });
            const data = await result.json();
            window.location.href = `/get-started?token=${data.token}`;
        } catch (error) {
            console.error('Apple Sign In failed:', error);
        }
    });
</script>

El manejo del backend para Apple es mas complejo porque necesitamos validar el token de identidad JWT:

python# routes/auth/apple.py
from jose import jwt as jose_jwt
import httpx

APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"
APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"

async def get_apple_public_keys():
    """Obtener las claves publicas de Apple para validacion JWT."""
    async with httpx.AsyncClient() as client:
        response = await client.get(APPLE_KEYS_URL)
        return response.json()["keys"]

async def validate_apple_identity_token(id_token: str) -> dict: """Validar y decodificar el token de identidad JWT de Apple.""" # Obtener claves publicas de Apple apple_keys = await get_apple_public_keys() BLANK # Decodificar cabecera para encontrar el ID de clave header = jose_jwt.get_unverified_header(id_token) kid = header["kid"] BLANK # Encontrar clave coincidente key = next(k for k in apple_keys if k["kid"] == kid) BLANK # Verificar y decodificar payload = jose_jwt.decode( id_token, key, algorithms=["RS256"], audience=APPLE_CLIENT_ID, issuer="https://appleid.apple.com", ) BLANK return payload BLANK

@router.post("/auth/apple/callback") async def apple_callback(data: AppleCallbackData): # Validar el token de identidad identity = await validate_apple_identity_token(data.id_token) BLANK apple_user_id = identity["sub"] email = identity.get("email") BLANK # La informacion del usuario (nombre) solo llega en la primera autorizacion name = None if data.user: name_data = data.user.get("name", {}) first = name_data.get("firstName", "") last = name_data.get("lastName", "") name = f"{first} {last}".strip() or None BLANK user = await find_or_create_user( provider="apple", provider_id=apple_user_id, email=email, name=name, ) BLANK token = create_access_token(user.id) return {"token": token, "redirect": "/get-started"} ```

La validacion JWT es critica. Sin ella, cualquiera podria fabricar un token de identidad falso. Verificamos el token contra las claves publicas de Apple (obtenidas de su endpoint JWKS), comprobamos que la audiencia coincida con nuestro ID de cliente y verificamos que el emisor sea https://appleid.apple.com.

Estrategia de tokens: expiracion de 24 horas con renovacion

Los cuatro proveedores OAuth convergen en la misma salida: un token JWT de 0fee. El token tiene una expiracion de 24 horas con un mecanismo de renovacion:

python# services/auth.py
ACCESS_TOKEN_EXPIRY = timedelta(hours=24)
REFRESH_TOKEN_EXPIRY = timedelta(days=30)

def create_access_token(user_id: str) -> str:
    payload = {
        "sub": user_id,
        "type": "access",
        "exp": datetime.utcnow() + ACCESS_TOKEN_EXPIRY,
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def create_refresh_token(user_id: str) -> str: payload = { "sub": user_id, "type": "refresh", "exp": datetime.utcnow() + REFRESH_TOKEN_EXPIRY, } return jwt.encode(payload, SECRET_KEY, algorithm="HS256") BLANK

@router.post("/auth/refresh") async def refresh_token(refresh_token: str): payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=["HS256"]) if payload.get("type") != "refresh": raise HTTPException(401, "Invalid token type") BLANK new_access = create_access_token(payload["sub"]) return {"access_token": new_access, "expires_in": 86400} ```

El token de acceso de 24 horas significa que los usuarios no necesitan re-autenticarse durante un dia de trabajo. El token de renovacion de 30 dias significa que permanecen conectados entre sesiones. Cuando el token de acceso expira, el frontend utiliza silenciosamente el token de renovacion para obtener uno nuevo.

Redireccion post-autenticacion: el flujo /get-started

Despues de una autenticacion exitosa, los cuatro flujos OAuth redirigen a /get-started. Este es el asistente de incorporacion donde los nuevos usuarios:

  1. Eligen un nombre para mostrar
  2. Crean su primera aplicacion
  3. Generan su primera clave API
  4. Realizan un pago de prueba

Para los usuarios existentes, la redireccion salta al panel de control. La verificacion es simple:

python# En el frontend
async function handleAuthRedirect(token: string) {
    localStorage.setItem("access_token", token);

    const user = await fetchCurrentUser();

    if (user.apps && user.apps.length > 0) {
        // Usuario existente con apps -> ir al panel de control
        navigate("/dashboard");
    } else {
        // Nuevo usuario -> incorporacion
        navigate("/get-started");
    }
}

Logica de vinculacion de cuentas

La parte mas matizada del OAuth multi-proveedor es la vinculacion de cuentas. Que sucede cuando alguien se registra con Google, y luego intenta iniciar sesion con GitHub usando el mismo correo?

python# services/user.py
async def find_or_create_user(
    provider: str,
    provider_id: str,
    email: str | None,
    name: str | None,
) -> User:
    provider_column = f"{provider}_id"

    # Primero: verificar si este ID de proveedor ya esta vinculado
    user = await db.query(User).filter(
        getattr(User, provider_column) == provider_id
    ).first()

    if user:
        return user

    # Segundo: verificar si el correo coincide con un usuario existente
    if email:
        user = await db.query(User).filter(User.email == email).first()
        if user:
            # Vincular este proveedor a la cuenta existente
            setattr(user, provider_column, provider_id)
            await db.commit()
            return user

    # Tercero: crear un nuevo usuario
    user = User(
        id=generate_user_id(),
        email=email,
        name=name,
        **{provider_column: provider_id},
    )
    db.add(user)
    await db.commit()
    return user

El orden de busqueda importa: primero el ID del proveedor, luego coincidencia por correo, luego creacion de nueva cuenta. Esto asegura que un usuario que vincula multiples proveedores siempre termina con una sola cuenta, no con duplicados.

Lo que aprendimos

Apple es el proveedor OAuth mas dificil de implementar. El flujo popup, el callback POST, el token de identidad JWT y la informacion del usuario solo en el primer inicio de sesion lo hacen fundamentalmente diferente de los otros tres. Presupuesta el doble de tiempo para Apple.

Azure AD multi-inquilino es esencial para plataformas de desarrolladores. Restringir a un solo inquilino o a cuentas personales excluiria a una porcion significativa de desarrolladores. El endpoint /common maneja todos los tipos de cuenta.

La vinculacion de cuentas deberia basarse en correo para plataformas de desarrolladores. Los desarrolladores esperan que su cuenta de Google y su cuenta de GitHub con el mismo correo resulten en una sola cuenta de 0fee, no en dos.

Los tokens de renovacion previenen la fatiga de reconsentimiento OAuth. Sin tokens de renovacion, los usuarios necesitarian pasar por la pantalla de consentimiento OAuth cada 24 horas. El token de renovacion de 30 dias mantiene la experiencia fluida.


Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles