# SPEC-OAUTH2-SOCIAL-LOGIN: Autenticación OAuth2 y Login Social

## Metadata
- **Código**: SPEC-OAUTH2-SOCIAL-LOGIN
- **Versión**: 1.0.0
- **Fecha**: 2025-01-15
- **Gap Relacionado**: GAP-MGN-001-005
- **Módulo**: MGN-001 (Core/Seguridad)
- **Prioridad**: P1
- **Story Points**: 13
- **Odoo Referencia**: auth_oauth, auth_oauth_signup

---

## 1. Resumen Ejecutivo

### 1.1 Descripción del Gap

El sistema actual de autenticación solo soporta credenciales locales (usuario/contraseña). Se requiere implementar autenticación OAuth2 que permita a los usuarios iniciar sesión mediante proveedores externos como Google, Microsoft, Facebook y otros proveedores OpenID Connect compatibles.

### 1.2 Impacto en el Negocio

| Aspecto | Sin OAuth2 | Con OAuth2 |
|---------|-----------|------------|
| Fricción de acceso | Alta (crear cuenta) | Baja (1-click login) |
| Gestión contraseñas | Por usuario | Delegada al proveedor |
| Seguridad | Depende de usuario | Hereda del proveedor (MFA, etc.) |
| Onboarding | Manual | Automático |
| Integración empresarial | Ninguna | Azure AD, Google Workspace |
| Compliance | N/A | SOC2, ISO 27001 del proveedor |

### 1.3 Objetivos de la Especificación

1. Implementar flujo OAuth2 Authorization Code con PKCE
2. Soportar proveedores principales (Google, Microsoft, Facebook)
3. Permitir configuración de proveedores personalizados
4. Gestionar vinculación de cuentas OAuth con usuarios existentes
5. Mantener compatibilidad con autenticación local
6. Integrar con sistema de 2FA existente

---

## 2. Arquitectura de Datos

### 2.1 Diagrama de Flujo OAuth2

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         FLUJO OAUTH2 AUTHORIZATION CODE                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────┐     1. Login Request    ┌─────────────┐                        │
│  │ Usuario │ ──────────────────────► │  Frontend   │                        │
│  │ Browser │                         │  (React)    │                        │
│  └────┬────┘                         └──────┬──────┘                        │
│       │                                     │                               │
│       │ 2. Redirect to Provider             │                               │
│       │ ◄───────────────────────────────────┘                               │
│       │                                                                     │
│       │ 3. OAuth Authorization               ┌─────────────────┐            │
│       └─────────────────────────────────────►│  OAuth Provider │            │
│                                              │  (Google, MS)   │            │
│       ┌──────────────────────────────────────┤                 │            │
│       │ 4. Authorization Code + State        └─────────────────┘            │
│       │                                                                     │
│       ▼                                                                     │
│  ┌─────────────┐  5. Code + State   ┌─────────────┐                         │
│  │  Callback   │ ─────────────────► │   Backend   │                         │
│  │  Endpoint   │                    │   (FastAPI) │                         │
│  └─────────────┘                    └──────┬──────┘                         │
│                                            │                                │
│                                            │ 6. Exchange Code for Token     │
│                                            ▼                                │
│                                     ┌─────────────────┐                     │
│                                     │  OAuth Provider │                     │
│                                     │  Token Endpoint │                     │
│                                     └───────┬─────────┘                     │
│                                             │                               │
│       ┌─────────────────────────────────────┘                               │
│       │ 7. Access Token + ID Token                                          │
│       ▼                                                                     │
│  ┌─────────────┐  8. Validate Token  ┌─────────────────┐                    │
│  │   Backend   │ ──────────────────► │  OAuth Provider │                    │
│  │             │                     │  UserInfo       │                    │
│  └──────┬──────┘                     └───────┬─────────┘                    │
│         │                                    │                              │
│         │ ◄──────────────────────────────────┘                              │
│         │ 9. User Profile Data                                              │
│         │                                                                   │
│         │ 10. Create/Link User + Session                                    │
│         ▼                                                                   │
│  ┌─────────────┐  11. JWT Token     ┌─────────┐                             │
│  │   Backend   │ ─────────────────► │ Usuario │                             │
│  │             │                    │ Browser │                             │
│  └─────────────┘                    └─────────┘                             │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### 2.2 Definición de Tablas

```sql
-- =============================================================================
-- SCHEMA: core
-- =============================================================================

-- -----------------------------------------------------------------------------
-- Tabla: oauth_providers (Proveedores OAuth2)
-- -----------------------------------------------------------------------------
CREATE TABLE core.oauth_providers (
    -- Identificación
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    code                VARCHAR(50) NOT NULL UNIQUE,
    name                VARCHAR(100) NOT NULL,

    -- Configuración OAuth2
    client_id           VARCHAR(255) NOT NULL,
    client_secret       VARCHAR(500),  -- Encriptado con AES-256

    -- Endpoints OAuth2
    authorization_endpoint  VARCHAR(500) NOT NULL,
    token_endpoint          VARCHAR(500) NOT NULL,
    userinfo_endpoint       VARCHAR(500) NOT NULL,
    jwks_uri               VARCHAR(500),  -- Para validación de ID tokens

    -- Scopes y parámetros
    scope               VARCHAR(500) NOT NULL DEFAULT 'openid profile email',
    response_type       VARCHAR(50) NOT NULL DEFAULT 'code',

    -- PKCE Configuration
    pkce_enabled        BOOLEAN NOT NULL DEFAULT TRUE,
    code_challenge_method VARCHAR(10) DEFAULT 'S256',

    -- Mapeo de claims
    claim_mapping       JSONB NOT NULL DEFAULT '{
        "sub": "oauth_uid",
        "email": "email",
        "name": "name",
        "picture": "avatar_url"
    }'::jsonb,

    -- UI
    icon_class          VARCHAR(100),  -- fa-google, fa-microsoft, etc.
    button_text         VARCHAR(100),
    button_color        VARCHAR(20),
    display_order       INTEGER NOT NULL DEFAULT 10,

    -- Estado
    is_enabled          BOOLEAN NOT NULL DEFAULT FALSE,
    is_visible          BOOLEAN NOT NULL DEFAULT TRUE,

    -- Restricciones
    allowed_domains     TEXT[],  -- NULL = todos permitidos
    auto_create_users   BOOLEAN NOT NULL DEFAULT FALSE,
    default_role_id     UUID REFERENCES core.roles(id),

    -- Multi-tenant
    company_id          UUID REFERENCES core.companies(id),
    -- NULL = disponible para todas las empresas

    -- Auditoría
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by          UUID NOT NULL REFERENCES core.users(id),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by          UUID NOT NULL REFERENCES core.users(id),

    -- Restricciones
    CONSTRAINT chk_response_type CHECK (response_type IN ('code', 'token')),
    CONSTRAINT chk_pkce_method CHECK (code_challenge_method IN ('S256', 'plain'))
);

-- Índices
CREATE INDEX idx_oauth_providers_enabled ON core.oauth_providers(is_enabled);
CREATE INDEX idx_oauth_providers_company ON core.oauth_providers(company_id);

-- -----------------------------------------------------------------------------
-- Tabla: oauth_user_links (Vinculación usuario-proveedor)
-- -----------------------------------------------------------------------------
CREATE TABLE core.oauth_user_links (
    -- Identificación
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id             UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
    provider_id         UUID NOT NULL REFERENCES core.oauth_providers(id) ON DELETE CASCADE,

    -- Identificación OAuth
    oauth_uid           VARCHAR(255) NOT NULL,  -- Subject ID del proveedor
    oauth_email         VARCHAR(255),

    -- Tokens (encriptados)
    access_token        TEXT,
    refresh_token       TEXT,
    id_token            TEXT,
    token_expires_at    TIMESTAMPTZ,

    -- Metadata
    raw_userinfo        JSONB,  -- Datos completos del proveedor
    last_login_at       TIMESTAMPTZ,
    login_count         INTEGER NOT NULL DEFAULT 0,

    -- Auditoría
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

    -- Restricciones
    CONSTRAINT uq_provider_oauth_uid UNIQUE (provider_id, oauth_uid),
    CONSTRAINT uq_user_provider UNIQUE (user_id, provider_id)
);

-- Índices
CREATE INDEX idx_oauth_links_user ON core.oauth_user_links(user_id);
CREATE INDEX idx_oauth_links_provider ON core.oauth_user_links(provider_id);
CREATE INDEX idx_oauth_links_oauth_uid ON core.oauth_user_links(oauth_uid);

-- -----------------------------------------------------------------------------
-- Tabla: oauth_states (Estados OAuth2 temporales para CSRF)
-- -----------------------------------------------------------------------------
CREATE TABLE core.oauth_states (
    id                  UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    state               VARCHAR(64) NOT NULL UNIQUE,

    -- PKCE
    code_verifier       VARCHAR(128),

    -- Contexto
    provider_id         UUID NOT NULL REFERENCES core.oauth_providers(id),
    redirect_uri        VARCHAR(500) NOT NULL,
    return_url          VARCHAR(500),

    -- Vinculación con usuario existente (para linking)
    link_user_id        UUID REFERENCES core.users(id),

    -- Metadata
    ip_address          INET,
    user_agent          TEXT,

    -- Tiempo de vida
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at          TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '10 minutes'),
    used_at             TIMESTAMPTZ,

    -- Restricciones
    CONSTRAINT chk_not_expired CHECK (expires_at > created_at)
);

-- Índices
CREATE INDEX idx_oauth_states_state ON core.oauth_states(state);
CREATE INDEX idx_oauth_states_expires ON core.oauth_states(expires_at);

-- Limpieza automática de estados expirados
CREATE OR REPLACE FUNCTION core.cleanup_expired_oauth_states()
RETURNS void AS $$
BEGIN
    DELETE FROM core.oauth_states
    WHERE expires_at < CURRENT_TIMESTAMP
       OR used_at IS NOT NULL;
END;
$$ LANGUAGE plpgsql;

-- Extensión: users (campos OAuth)
ALTER TABLE core.users
ADD COLUMN IF NOT EXISTS oauth_only BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS primary_oauth_provider_id UUID REFERENCES core.oauth_providers(id);

-- Vista: usuarios con sus proveedores OAuth vinculados
CREATE OR REPLACE VIEW core.users_oauth_summary AS
SELECT
    u.id,
    u.email,
    u.name,
    u.oauth_only,
    COUNT(ol.id) as linked_providers_count,
    ARRAY_AGG(op.name) FILTER (WHERE op.id IS NOT NULL) as linked_provider_names,
    MAX(ol.last_login_at) as last_oauth_login
FROM core.users u
LEFT JOIN core.oauth_user_links ol ON ol.user_id = u.id
LEFT JOIN core.oauth_providers op ON op.id = ol.provider_id
GROUP BY u.id;
```

### 2.3 Datos Iniciales de Proveedores

```sql
-- Proveedores OAuth2 preconfigurados
INSERT INTO core.oauth_providers (
    code, name,
    authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri,
    scope, icon_class, button_text, button_color,
    claim_mapping, display_order, created_by
) VALUES
-- Google
(
    'google', 'Google',
    'https://accounts.google.com/o/oauth2/v2/auth',
    'https://oauth2.googleapis.com/token',
    'https://openidconnect.googleapis.com/v1/userinfo',
    'https://www.googleapis.com/oauth2/v3/certs',
    'openid profile email',
    'fa-google', 'Continuar con Google', '#4285F4',
    '{"sub": "oauth_uid", "email": "email", "name": "name", "picture": "avatar_url"}',
    1, '00000000-0000-0000-0000-000000000001'
),
-- Microsoft Azure AD
(
    'microsoft', 'Microsoft',
    'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
    'https://login.microsoftonline.com/common/oauth2/v2.0/token',
    'https://graph.microsoft.com/v1.0/me',
    'https://login.microsoftonline.com/common/discovery/v2.0/keys',
    'openid profile email User.Read',
    'fa-microsoft', 'Continuar con Microsoft', '#00A4EF',
    '{"id": "oauth_uid", "mail": "email", "displayName": "name"}',
    2, '00000000-0000-0000-0000-000000000001'
),
-- Facebook
(
    'facebook', 'Facebook',
    'https://www.facebook.com/v18.0/dialog/oauth',
    'https://graph.facebook.com/v18.0/oauth/access_token',
    'https://graph.facebook.com/v18.0/me?fields=id,name,email,picture',
    NULL,
    'email public_profile',
    'fa-facebook', 'Continuar con Facebook', '#1877F2',
    '{"id": "oauth_uid", "email": "email", "name": "name", "picture.data.url": "avatar_url"}',
    3, '00000000-0000-0000-0000-000000000001'
),
-- GitHub
(
    'github', 'GitHub',
    'https://github.com/login/oauth/authorize',
    'https://github.com/login/oauth/access_token',
    'https://api.github.com/user',
    NULL,
    'read:user user:email',
    'fa-github', 'Continuar con GitHub', '#333333',
    '{"id": "oauth_uid", "email": "email", "name": "name", "avatar_url": "avatar_url"}',
    4, '00000000-0000-0000-0000-000000000001'
);
```

---

## 3. Flujo de Autenticación OAuth2

### 3.1 Servicio Principal OAuth2

```python
# services/oauth2_service.py

import secrets
import hashlib
import base64
from typing import Optional, Dict, Any, Tuple
from uuid import UUID
from datetime import datetime, timedelta
from urllib.parse import urlencode, parse_qs
import httpx
from jose import jwt, JWTError
from cryptography.fernet import Fernet

from dataclasses import dataclass
from enum import Enum

class OAuthError(Exception):
    """Error base para OAuth2."""
    def __init__(self, error: str, description: str = None):
        self.error = error
        self.description = description
        super().__init__(f"{error}: {description}" if description else error)

class OAuthErrorCode(str, Enum):
    INVALID_STATE = "invalid_state"
    STATE_EXPIRED = "state_expired"
    INVALID_CODE = "invalid_code"
    TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
    USERINFO_FAILED = "userinfo_failed"
    PROVIDER_NOT_FOUND = "provider_not_found"
    PROVIDER_DISABLED = "provider_disabled"
    DOMAIN_NOT_ALLOWED = "domain_not_allowed"
    USER_CREATION_DISABLED = "user_creation_disabled"
    ACCOUNT_LINKING_FAILED = "account_linking_failed"

@dataclass
class OAuthUserInfo:
    """Información del usuario obtenida del proveedor."""
    oauth_uid: str
    email: str
    name: Optional[str]
    avatar_url: Optional[str]
    email_verified: bool
    raw_data: Dict[str, Any]


class OAuth2Service:
    """Servicio principal para autenticación OAuth2."""

    def __init__(
        self,
        db_session,
        user_service,
        session_service,
        encryption_key: str,
        base_url: str
    ):
        self.db = db_session
        self.user_service = user_service
        self.session_service = session_service
        self.fernet = Fernet(encryption_key.encode())
        self.base_url = base_url
        self.http_client = httpx.AsyncClient(timeout=10.0)

    # =========================================================================
    # PASO 1: Generar URL de autorización
    # =========================================================================

    async def get_authorization_url(
        self,
        provider_code: str,
        return_url: Optional[str] = None,
        link_user_id: Optional[UUID] = None,
        ip_address: Optional[str] = None,
        user_agent: Optional[str] = None
    ) -> Tuple[str, str]:
        """
        Genera URL de autorización OAuth2.

        Args:
            provider_code: Código del proveedor (google, microsoft, etc.)
            return_url: URL a donde redirigir después del login
            link_user_id: Si se especifica, vincula OAuth a usuario existente
            ip_address: IP del cliente (para auditoría)
            user_agent: User-Agent del navegador

        Returns:
            Tuple[authorization_url, state]
        """
        # Obtener configuración del proveedor
        provider = await self._get_provider(provider_code)

        if not provider.is_enabled:
            raise OAuthError(OAuthErrorCode.PROVIDER_DISABLED)

        # Generar state (CSRF protection)
        state = secrets.token_urlsafe(32)

        # Generar PKCE code_verifier y code_challenge
        code_verifier = None
        code_challenge = None

        if provider.pkce_enabled:
            code_verifier = secrets.token_urlsafe(64)

            if provider.code_challenge_method == 'S256':
                code_challenge = base64.urlsafe_b64encode(
                    hashlib.sha256(code_verifier.encode()).digest()
                ).decode().rstrip('=')
            else:
                code_challenge = code_verifier

        # Construir redirect_uri
        redirect_uri = f"{self.base_url}/api/v1/auth/oauth/callback/{provider_code}"

        # Guardar estado en BD
        await self._save_oauth_state(
            state=state,
            provider_id=provider.id,
            code_verifier=code_verifier,
            redirect_uri=redirect_uri,
            return_url=return_url,
            link_user_id=link_user_id,
            ip_address=ip_address,
            user_agent=user_agent
        )

        # Construir URL de autorización
        params = {
            'client_id': provider.client_id,
            'redirect_uri': redirect_uri,
            'response_type': provider.response_type,
            'scope': provider.scope,
            'state': state,
        }

        if code_challenge:
            params['code_challenge'] = code_challenge
            params['code_challenge_method'] = provider.code_challenge_method

        # Parámetros adicionales por proveedor
        if provider_code == 'google':
            params['access_type'] = 'offline'  # Para obtener refresh_token
            params['prompt'] = 'consent'
        elif provider_code == 'microsoft':
            params['response_mode'] = 'query'

        authorization_url = f"{provider.authorization_endpoint}?{urlencode(params)}"

        return authorization_url, state

    # =========================================================================
    # PASO 2: Procesar callback
    # =========================================================================

    async def handle_callback(
        self,
        provider_code: str,
        code: str,
        state: str,
        error: Optional[str] = None,
        error_description: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Procesa el callback del proveedor OAuth2.

        Args:
            provider_code: Código del proveedor
            code: Authorization code
            state: State parameter (CSRF)
            error: Error del proveedor (si hay)
            error_description: Descripción del error

        Returns:
            Dict con session_token, user, y return_url

        Raises:
            OAuthError: Si hay error en el proceso
        """
        # Verificar si el proveedor retornó error
        if error:
            raise OAuthError(error, error_description)

        # Validar state
        oauth_state = await self._validate_and_consume_state(state)

        if oauth_state.provider.code != provider_code:
            raise OAuthError(OAuthErrorCode.INVALID_STATE, "Provider mismatch")

        provider = oauth_state.provider

        # Intercambiar code por tokens
        tokens = await self._exchange_code_for_tokens(
            provider=provider,
            code=code,
            redirect_uri=oauth_state.redirect_uri,
            code_verifier=oauth_state.code_verifier
        )

        # Obtener información del usuario
        user_info = await self._get_user_info(
            provider=provider,
            access_token=tokens['access_token'],
            id_token=tokens.get('id_token')
        )

        # Validar dominio si hay restricciones
        await self._validate_email_domain(provider, user_info.email)

        # Determinar si es linking o login
        if oauth_state.link_user_id:
            # Vincular OAuth a usuario existente
            user = await self._link_oauth_to_user(
                user_id=oauth_state.link_user_id,
                provider=provider,
                user_info=user_info,
                tokens=tokens
            )
        else:
            # Login o crear usuario
            user = await self._authenticate_or_create_user(
                provider=provider,
                user_info=user_info,
                tokens=tokens
            )

        # Crear sesión
        session = await self.session_service.create_session(
            user_id=user.id,
            auth_method='oauth',
            oauth_provider=provider_code,
            ip_address=oauth_state.ip_address,
            user_agent=oauth_state.user_agent
        )

        return {
            'session_token': session.token,
            'user': user,
            'return_url': oauth_state.return_url
        }

    # =========================================================================
    # Intercambio de código por tokens
    # =========================================================================

    async def _exchange_code_for_tokens(
        self,
        provider: 'OAuthProvider',
        code: str,
        redirect_uri: str,
        code_verifier: Optional[str]
    ) -> Dict[str, Any]:
        """Intercambia authorization code por access_token."""

        data = {
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': redirect_uri,
            'client_id': provider.client_id,
        }

        # Agregar client_secret si existe
        if provider.client_secret:
            data['client_secret'] = self._decrypt(provider.client_secret)

        # Agregar PKCE verifier
        if code_verifier:
            data['code_verifier'] = code_verifier

        # Headers según proveedor
        headers = {'Accept': 'application/json'}

        try:
            response = await self.http_client.post(
                provider.token_endpoint,
                data=data,
                headers=headers
            )

            if response.status_code != 200:
                error_data = response.json() if response.content else {}
                raise OAuthError(
                    OAuthErrorCode.TOKEN_EXCHANGE_FAILED,
                    error_data.get('error_description', response.text)
                )

            return response.json()

        except httpx.HTTPError as e:
            raise OAuthError(
                OAuthErrorCode.TOKEN_EXCHANGE_FAILED,
                str(e)
            )

    # =========================================================================
    # Obtener información del usuario
    # =========================================================================

    async def _get_user_info(
        self,
        provider: 'OAuthProvider',
        access_token: str,
        id_token: Optional[str] = None
    ) -> OAuthUserInfo:
        """Obtiene información del usuario desde el proveedor."""

        raw_data = {}

        # Si hay ID token (OIDC), extraer claims
        if id_token and provider.jwks_uri:
            try:
                raw_data = await self._verify_and_decode_id_token(
                    provider=provider,
                    id_token=id_token
                )
            except JWTError:
                pass  # Fallback a userinfo endpoint

        # Llamar al userinfo endpoint
        if not raw_data or 'email' not in raw_data:
            headers = {
                'Authorization': f'Bearer {access_token}',
                'Accept': 'application/json'
            }

            try:
                response = await self.http_client.get(
                    provider.userinfo_endpoint,
                    headers=headers
                )

                if response.status_code != 200:
                    raise OAuthError(
                        OAuthErrorCode.USERINFO_FAILED,
                        f"Status {response.status_code}"
                    )

                userinfo_data = response.json()
                raw_data.update(userinfo_data)

            except httpx.HTTPError as e:
                raise OAuthError(OAuthErrorCode.USERINFO_FAILED, str(e))

        # Mapear claims según configuración del proveedor
        claim_mapping = provider.claim_mapping

        oauth_uid = self._extract_claim(raw_data, claim_mapping.get('sub', 'sub'))
        email = self._extract_claim(raw_data, claim_mapping.get('email', 'email'))
        name = self._extract_claim(raw_data, claim_mapping.get('name', 'name'))
        avatar_url = self._extract_claim(raw_data, claim_mapping.get('picture', 'picture'))

        if not oauth_uid:
            raise OAuthError(OAuthErrorCode.USERINFO_FAILED, "Missing subject claim")

        return OAuthUserInfo(
            oauth_uid=str(oauth_uid),
            email=email,
            name=name,
            avatar_url=avatar_url,
            email_verified=raw_data.get('email_verified', False),
            raw_data=raw_data
        )

    def _extract_claim(self, data: Dict, path: str) -> Optional[Any]:
        """Extrae un claim usando notación de punto (ej: 'picture.data.url')."""
        keys = path.split('.')
        value = data

        for key in keys:
            if isinstance(value, dict):
                value = value.get(key)
            else:
                return None

        return value

    # =========================================================================
    # Verificar ID Token (OIDC)
    # =========================================================================

    async def _verify_and_decode_id_token(
        self,
        provider: 'OAuthProvider',
        id_token: str
    ) -> Dict[str, Any]:
        """Verifica y decodifica ID token usando JWKS."""

        # Obtener JWKS del proveedor
        response = await self.http_client.get(provider.jwks_uri)
        jwks = response.json()

        # Decodificar header para obtener kid
        unverified_header = jwt.get_unverified_header(id_token)
        kid = unverified_header.get('kid')

        # Buscar la clave correcta
        key = None
        for k in jwks.get('keys', []):
            if k.get('kid') == kid:
                key = k
                break

        if not key:
            raise JWTError("Key not found")

        # Verificar y decodificar
        claims = jwt.decode(
            id_token,
            key,
            algorithms=['RS256'],
            audience=provider.client_id,
            options={'verify_exp': True}
        )

        return claims

    # =========================================================================
    # Autenticar o crear usuario
    # =========================================================================

    async def _authenticate_or_create_user(
        self,
        provider: 'OAuthProvider',
        user_info: OAuthUserInfo,
        tokens: Dict[str, Any]
    ) -> 'User':
        """
        Busca usuario existente o crea uno nuevo.

        Proceso:
        1. Buscar por oauth_uid + provider (vinculación existente)
        2. Buscar por email
        3. Crear nuevo usuario si permitido
        """
        # Buscar vinculación OAuth existente
        existing_link = await self.db.execute(
            """
            SELECT user_id FROM core.oauth_user_links
            WHERE provider_id = :provider_id AND oauth_uid = :oauth_uid
            """,
            {'provider_id': provider.id, 'oauth_uid': user_info.oauth_uid}
        )

        if existing_link:
            # Usuario ya vinculado, actualizar tokens y retornar
            user = await self.user_service.get(existing_link.user_id)
            await self._update_oauth_link(
                user_id=user.id,
                provider_id=provider.id,
                user_info=user_info,
                tokens=tokens
            )
            return user

        # Buscar usuario por email
        if user_info.email:
            existing_user = await self.user_service.get_by_email(user_info.email)

            if existing_user:
                # Vincular OAuth a usuario existente
                await self._create_oauth_link(
                    user_id=existing_user.id,
                    provider=provider,
                    user_info=user_info,
                    tokens=tokens
                )
                return existing_user

        # Crear nuevo usuario si está permitido
        if not provider.auto_create_users:
            raise OAuthError(
                OAuthErrorCode.USER_CREATION_DISABLED,
                "La creación automática de usuarios no está habilitada"
            )

        new_user = await self._create_user_from_oauth(
            provider=provider,
            user_info=user_info,
            tokens=tokens
        )

        return new_user

    async def _create_user_from_oauth(
        self,
        provider: 'OAuthProvider',
        user_info: OAuthUserInfo,
        tokens: Dict[str, Any]
    ) -> 'User':
        """Crea un nuevo usuario desde datos OAuth."""

        user_data = {
            'email': user_info.email,
            'name': user_info.name or user_info.email.split('@')[0],
            'avatar_url': user_info.avatar_url,
            'is_active': True,
            'email_verified': user_info.email_verified,
            'oauth_only': True,
            'primary_oauth_provider_id': provider.id,
        }

        if provider.default_role_id:
            user_data['role_ids'] = [provider.default_role_id]

        new_user = await self.user_service.create(user_data)

        # Crear vinculación OAuth
        await self._create_oauth_link(
            user_id=new_user.id,
            provider=provider,
            user_info=user_info,
            tokens=tokens
        )

        return new_user

    # =========================================================================
    # Gestión de vinculaciones OAuth
    # =========================================================================

    async def _create_oauth_link(
        self,
        user_id: UUID,
        provider: 'OAuthProvider',
        user_info: OAuthUserInfo,
        tokens: Dict[str, Any]
    ):
        """Crea una nueva vinculación OAuth."""

        await self.db.execute(
            """
            INSERT INTO core.oauth_user_links (
                user_id, provider_id, oauth_uid, oauth_email,
                access_token, refresh_token, id_token,
                token_expires_at, raw_userinfo, last_login_at, login_count
            ) VALUES (
                :user_id, :provider_id, :oauth_uid, :email,
                :access_token, :refresh_token, :id_token,
                :expires_at, :raw_userinfo, CURRENT_TIMESTAMP, 1
            )
            """,
            {
                'user_id': user_id,
                'provider_id': provider.id,
                'oauth_uid': user_info.oauth_uid,
                'email': user_info.email,
                'access_token': self._encrypt(tokens.get('access_token')),
                'refresh_token': self._encrypt(tokens.get('refresh_token')),
                'id_token': self._encrypt(tokens.get('id_token')),
                'expires_at': self._calculate_token_expiry(tokens),
                'raw_userinfo': user_info.raw_data
            }
        )

    async def _update_oauth_link(
        self,
        user_id: UUID,
        provider_id: UUID,
        user_info: OAuthUserInfo,
        tokens: Dict[str, Any]
    ):
        """Actualiza una vinculación OAuth existente."""

        await self.db.execute(
            """
            UPDATE core.oauth_user_links SET
                access_token = :access_token,
                refresh_token = COALESCE(:refresh_token, refresh_token),
                id_token = :id_token,
                token_expires_at = :expires_at,
                raw_userinfo = :raw_userinfo,
                last_login_at = CURRENT_TIMESTAMP,
                login_count = login_count + 1,
                updated_at = CURRENT_TIMESTAMP
            WHERE user_id = :user_id AND provider_id = :provider_id
            """,
            {
                'user_id': user_id,
                'provider_id': provider_id,
                'access_token': self._encrypt(tokens.get('access_token')),
                'refresh_token': self._encrypt(tokens.get('refresh_token')),
                'id_token': self._encrypt(tokens.get('id_token')),
                'expires_at': self._calculate_token_expiry(tokens),
                'raw_userinfo': user_info.raw_data
            }
        )

    async def _link_oauth_to_user(
        self,
        user_id: UUID,
        provider: 'OAuthProvider',
        user_info: OAuthUserInfo,
        tokens: Dict[str, Any]
    ) -> 'User':
        """Vincula un proveedor OAuth a un usuario existente."""

        # Verificar que el usuario existe
        user = await self.user_service.get(user_id)
        if not user:
            raise OAuthError(OAuthErrorCode.ACCOUNT_LINKING_FAILED, "User not found")

        # Verificar que el oauth_uid no está vinculado a otro usuario
        existing = await self.db.execute(
            """
            SELECT user_id FROM core.oauth_user_links
            WHERE provider_id = :provider_id AND oauth_uid = :oauth_uid
            """,
            {'provider_id': provider.id, 'oauth_uid': user_info.oauth_uid}
        )

        if existing and existing.user_id != user_id:
            raise OAuthError(
                OAuthErrorCode.ACCOUNT_LINKING_FAILED,
                "Esta cuenta OAuth ya está vinculada a otro usuario"
            )

        # Crear o actualizar vinculación
        if existing:
            await self._update_oauth_link(user_id, provider.id, user_info, tokens)
        else:
            await self._create_oauth_link(user_id, provider, user_info, tokens)

        return user

    async def unlink_oauth_provider(
        self,
        user_id: UUID,
        provider_code: str
    ):
        """Desvincula un proveedor OAuth del usuario."""

        provider = await self._get_provider(provider_code)

        # Verificar que el usuario no quede sin método de autenticación
        user = await self.user_service.get(user_id)

        links_count = await self.db.execute(
            "SELECT COUNT(*) FROM core.oauth_user_links WHERE user_id = :user_id",
            {'user_id': user_id}
        )

        if user.oauth_only and links_count.count <= 1:
            raise OAuthError(
                OAuthErrorCode.ACCOUNT_LINKING_FAILED,
                "No puede desvincular el único método de autenticación"
            )

        await self.db.execute(
            """
            DELETE FROM core.oauth_user_links
            WHERE user_id = :user_id AND provider_id = :provider_id
            """,
            {'user_id': user_id, 'provider_id': provider.id}
        )

    # =========================================================================
    # Validaciones
    # =========================================================================

    async def _validate_email_domain(
        self,
        provider: 'OAuthProvider',
        email: str
    ):
        """Valida que el dominio del email esté permitido."""

        if not provider.allowed_domains:
            return  # Sin restricciones

        if not email:
            raise OAuthError(OAuthErrorCode.DOMAIN_NOT_ALLOWED, "Email required")

        domain = email.split('@')[-1].lower()

        allowed = [d.lower() for d in provider.allowed_domains]

        if domain not in allowed:
            raise OAuthError(
                OAuthErrorCode.DOMAIN_NOT_ALLOWED,
                f"El dominio {domain} no está permitido"
            )

    async def _validate_and_consume_state(self, state: str) -> 'OAuthState':
        """Valida y consume el state OAuth2."""

        result = await self.db.execute(
            """
            UPDATE core.oauth_states SET used_at = CURRENT_TIMESTAMP
            WHERE state = :state
              AND expires_at > CURRENT_TIMESTAMP
              AND used_at IS NULL
            RETURNING *
            """,
            {'state': state}
        )

        if not result:
            raise OAuthError(OAuthErrorCode.INVALID_STATE)

        # Obtener proveedor
        provider = await self._get_provider_by_id(result.provider_id)
        result.provider = provider

        return result

    async def _save_oauth_state(
        self,
        state: str,
        provider_id: UUID,
        code_verifier: Optional[str],
        redirect_uri: str,
        return_url: Optional[str],
        link_user_id: Optional[UUID],
        ip_address: Optional[str],
        user_agent: Optional[str]
    ):
        """Guarda estado OAuth2 para validación posterior."""

        await self.db.execute(
            """
            INSERT INTO core.oauth_states (
                state, provider_id, code_verifier, redirect_uri,
                return_url, link_user_id, ip_address, user_agent
            ) VALUES (
                :state, :provider_id, :code_verifier, :redirect_uri,
                :return_url, :link_user_id, :ip_address, :user_agent
            )
            """,
            {
                'state': state,
                'provider_id': provider_id,
                'code_verifier': code_verifier,
                'redirect_uri': redirect_uri,
                'return_url': return_url,
                'link_user_id': link_user_id,
                'ip_address': ip_address,
                'user_agent': user_agent
            }
        )

    # =========================================================================
    # Utilidades
    # =========================================================================

    async def _get_provider(self, code: str) -> 'OAuthProvider':
        """Obtiene proveedor por código."""
        result = await self.db.execute(
            "SELECT * FROM core.oauth_providers WHERE code = :code",
            {'code': code}
        )
        if not result:
            raise OAuthError(OAuthErrorCode.PROVIDER_NOT_FOUND)
        return result

    async def _get_provider_by_id(self, provider_id: UUID) -> 'OAuthProvider':
        """Obtiene proveedor por ID."""
        result = await self.db.execute(
            "SELECT * FROM core.oauth_providers WHERE id = :id",
            {'id': provider_id}
        )
        if not result:
            raise OAuthError(OAuthErrorCode.PROVIDER_NOT_FOUND)
        return result

    def _encrypt(self, value: Optional[str]) -> Optional[str]:
        """Encripta un valor sensible."""
        if not value:
            return None
        return self.fernet.encrypt(value.encode()).decode()

    def _decrypt(self, value: Optional[str]) -> Optional[str]:
        """Desencripta un valor sensible."""
        if not value:
            return None
        return self.fernet.decrypt(value.encode()).decode()

    def _calculate_token_expiry(self, tokens: Dict[str, Any]) -> Optional[datetime]:
        """Calcula la fecha de expiración del token."""
        expires_in = tokens.get('expires_in')
        if expires_in:
            return datetime.utcnow() + timedelta(seconds=expires_in)
        return None
```

---

## 4. API REST

### 4.1 Endpoints

```yaml
# OAuth2 Authentication
GET    /api/v1/auth/oauth/providers              # Listar proveedores habilitados
GET    /api/v1/auth/oauth/authorize/{provider}   # Obtener URL de autorización
GET    /api/v1/auth/oauth/callback/{provider}    # Callback del proveedor (redirect)
POST   /api/v1/auth/oauth/callback/{provider}    # Callback del proveedor (POST)

# Gestión de vinculaciones (usuario autenticado)
GET    /api/v1/auth/oauth/links                  # Ver mis proveedores vinculados
POST   /api/v1/auth/oauth/link/{provider}        # Vincular nuevo proveedor
DELETE /api/v1/auth/oauth/link/{provider}        # Desvincular proveedor

# Administración (admin)
GET    /api/v1/admin/oauth/providers             # Listar todos los proveedores
POST   /api/v1/admin/oauth/providers             # Crear proveedor
PUT    /api/v1/admin/oauth/providers/{id}        # Actualizar proveedor
DELETE /api/v1/admin/oauth/providers/{id}        # Eliminar proveedor
POST   /api/v1/admin/oauth/providers/{id}/test   # Probar configuración
```

### 4.2 Schemas

```python
# schemas/oauth_schemas.py

from pydantic import BaseModel, Field, HttpUrl
from typing import Optional, List, Dict, Any
from uuid import UUID
from datetime import datetime

# ================== PROVIDERS (Público) ==================

class OAuthProviderPublic(BaseModel):
    """Información pública del proveedor para la UI de login."""
    code: str
    name: str
    icon_class: Optional[str]
    button_text: str
    button_color: Optional[str]
    authorization_url: str  # URL completa con state

class OAuthProvidersListResponse(BaseModel):
    providers: List[OAuthProviderPublic]

# ================== AUTHORIZATION ==================

class OAuthAuthorizeResponse(BaseModel):
    """Respuesta con URL de autorización."""
    authorization_url: str
    state: str

class OAuthCallbackRequest(BaseModel):
    """Datos del callback OAuth2."""
    code: Optional[str] = None
    state: str
    error: Optional[str] = None
    error_description: Optional[str] = None

class OAuthCallbackResponse(BaseModel):
    """Respuesta exitosa del callback."""
    access_token: str
    token_type: str = "bearer"
    expires_in: int
    user: Dict[str, Any]
    redirect_url: Optional[str]

# ================== USER LINKS ==================

class OAuthUserLinkResponse(BaseModel):
    """Vinculación OAuth de un usuario."""
    id: UUID
    provider_code: str
    provider_name: str
    oauth_email: Optional[str]
    linked_at: datetime
    last_login_at: Optional[datetime]
    login_count: int

class OAuthUserLinksResponse(BaseModel):
    """Lista de vinculaciones del usuario."""
    links: List[OAuthUserLinkResponse]
    can_unlink: bool  # False si solo tiene un método de auth

# ================== ADMIN ==================

class OAuthProviderCreate(BaseModel):
    """Crear nuevo proveedor OAuth."""
    code: str = Field(min_length=2, max_length=50, regex=r'^[a-z0-9_]+$')
    name: str = Field(min_length=2, max_length=100)
    client_id: str
    client_secret: Optional[str] = None
    authorization_endpoint: HttpUrl
    token_endpoint: HttpUrl
    userinfo_endpoint: HttpUrl
    jwks_uri: Optional[HttpUrl] = None
    scope: str = "openid profile email"
    response_type: str = "code"
    pkce_enabled: bool = True
    code_challenge_method: str = "S256"
    claim_mapping: Dict[str, str] = Field(
        default={
            "sub": "oauth_uid",
            "email": "email",
            "name": "name",
            "picture": "avatar_url"
        }
    )
    icon_class: Optional[str] = None
    button_text: str
    button_color: Optional[str] = None
    display_order: int = 10
    is_enabled: bool = False
    allowed_domains: Optional[List[str]] = None
    auto_create_users: bool = False
    default_role_id: Optional[UUID] = None

class OAuthProviderUpdate(BaseModel):
    """Actualizar proveedor OAuth."""
    name: Optional[str] = None
    client_id: Optional[str] = None
    client_secret: Optional[str] = None
    authorization_endpoint: Optional[HttpUrl] = None
    token_endpoint: Optional[HttpUrl] = None
    userinfo_endpoint: Optional[HttpUrl] = None
    jwks_uri: Optional[HttpUrl] = None
    scope: Optional[str] = None
    pkce_enabled: Optional[bool] = None
    claim_mapping: Optional[Dict[str, str]] = None
    icon_class: Optional[str] = None
    button_text: Optional[str] = None
    button_color: Optional[str] = None
    display_order: Optional[int] = None
    is_enabled: Optional[bool] = None
    is_visible: Optional[bool] = None
    allowed_domains: Optional[List[str]] = None
    auto_create_users: Optional[bool] = None
    default_role_id: Optional[UUID] = None

class OAuthProviderAdminResponse(BaseModel):
    """Respuesta completa del proveedor (admin)."""
    id: UUID
    code: str
    name: str
    client_id: str
    has_client_secret: bool
    authorization_endpoint: str
    token_endpoint: str
    userinfo_endpoint: str
    jwks_uri: Optional[str]
    scope: str
    response_type: str
    pkce_enabled: bool
    code_challenge_method: str
    claim_mapping: Dict[str, str]
    icon_class: Optional[str]
    button_text: str
    button_color: Optional[str]
    display_order: int
    is_enabled: bool
    is_visible: bool
    allowed_domains: Optional[List[str]]
    auto_create_users: bool
    default_role_id: Optional[UUID]
    callback_url: str  # URL a configurar en el proveedor
    users_count: int  # Usuarios vinculados
    created_at: datetime
    updated_at: datetime

class OAuthProviderTestResult(BaseModel):
    """Resultado de prueba de configuración."""
    success: bool
    authorization_endpoint_reachable: bool
    token_endpoint_reachable: bool
    userinfo_endpoint_reachable: bool
    jwks_valid: Optional[bool]
    errors: List[str]
```

### 4.3 Implementación de Endpoints

```python
# routers/oauth_router.py

from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from typing import Optional
from uuid import UUID

router = APIRouter(prefix="/api/v1/auth/oauth", tags=["OAuth2"])

# ================== PÚBLICOS ==================

@router.get("/providers", response_model=OAuthProvidersListResponse)
async def list_providers(
    request: Request,
    return_url: Optional[str] = Query(None),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """
    Lista proveedores OAuth2 habilitados con URLs de autorización.

    Usado por la página de login para mostrar botones de social login.
    """
    providers = await service.list_enabled_providers()

    result = []
    for p in providers:
        auth_url, _ = await service.get_authorization_url(
            provider_code=p.code,
            return_url=return_url,
            ip_address=request.client.host,
            user_agent=request.headers.get('user-agent')
        )
        result.append(OAuthProviderPublic(
            code=p.code,
            name=p.name,
            icon_class=p.icon_class,
            button_text=p.button_text,
            button_color=p.button_color,
            authorization_url=auth_url
        ))

    return OAuthProvidersListResponse(providers=result)

@router.get("/authorize/{provider}")
async def get_authorization_url(
    provider: str,
    request: Request,
    return_url: Optional[str] = Query(None),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """
    Obtiene URL de autorización para un proveedor.

    Puede usarse para obtener la URL sin redirigir automáticamente.
    """
    auth_url, state = await service.get_authorization_url(
        provider_code=provider,
        return_url=return_url,
        ip_address=request.client.host,
        user_agent=request.headers.get('user-agent')
    )

    return OAuthAuthorizeResponse(
        authorization_url=auth_url,
        state=state
    )

@router.get("/callback/{provider}")
async def oauth_callback_get(
    provider: str,
    request: Request,
    code: Optional[str] = None,
    state: str = Query(...),
    error: Optional[str] = None,
    error_description: Optional[str] = None,
    service: OAuth2Service = Depends(get_oauth_service)
):
    """
    Callback OAuth2 (GET).

    Procesa la respuesta del proveedor OAuth2 y:
    1. Intercambia el code por tokens
    2. Obtiene información del usuario
    3. Crea o autentica al usuario
    4. Redirige con token de sesión
    """
    try:
        result = await service.handle_callback(
            provider_code=provider,
            code=code,
            state=state,
            error=error,
            error_description=error_description
        )

        # Construir URL de redirección con token
        redirect_url = result.get('return_url', '/dashboard')

        # Agregar token como query param o en cookie
        response = RedirectResponse(
            url=f"{redirect_url}?oauth_success=true",
            status_code=302
        )

        # Establecer cookie de sesión
        response.set_cookie(
            key="session_token",
            value=result['session_token'],
            httponly=True,
            secure=True,
            samesite="lax",
            max_age=86400  # 24 horas
        )

        return response

    except OAuthError as e:
        # Redirigir a login con error
        error_url = f"/login?oauth_error={e.error}"
        if e.description:
            error_url += f"&error_description={e.description}"
        return RedirectResponse(url=error_url, status_code=302)

# ================== USUARIO AUTENTICADO ==================

@router.get("/links", response_model=OAuthUserLinksResponse)
async def get_my_oauth_links(
    current_user: User = Depends(get_current_user),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Obtiene los proveedores OAuth vinculados al usuario actual."""
    links = await service.get_user_links(current_user.id)
    can_unlink = not current_user.oauth_only or len(links) > 1

    return OAuthUserLinksResponse(
        links=links,
        can_unlink=can_unlink
    )

@router.post("/link/{provider}")
async def link_oauth_provider(
    provider: str,
    request: Request,
    current_user: User = Depends(get_current_user),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """
    Inicia el proceso de vinculación de un proveedor OAuth.

    Retorna URL de autorización. El callback vinculará la cuenta.
    """
    auth_url, state = await service.get_authorization_url(
        provider_code=provider,
        return_url="/settings/security",
        link_user_id=current_user.id,
        ip_address=request.client.host,
        user_agent=request.headers.get('user-agent')
    )

    return {"authorization_url": auth_url, "state": state}

@router.delete("/link/{provider}")
async def unlink_oauth_provider(
    provider: str,
    current_user: User = Depends(get_current_user),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Desvincula un proveedor OAuth del usuario actual."""
    await service.unlink_oauth_provider(current_user.id, provider)
    return {"status": "unlinked"}

# ================== ADMINISTRACIÓN ==================

admin_router = APIRouter(prefix="/api/v1/admin/oauth", tags=["OAuth2 Admin"])

@admin_router.get("/providers", response_model=List[OAuthProviderAdminResponse])
async def admin_list_providers(
    include_disabled: bool = True,
    current_user: User = Depends(get_current_admin),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Lista todos los proveedores OAuth (admin)."""
    return await service.list_all_providers(include_disabled)

@admin_router.post("/providers", response_model=OAuthProviderAdminResponse)
async def admin_create_provider(
    data: OAuthProviderCreate,
    current_user: User = Depends(get_current_admin),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Crea un nuevo proveedor OAuth."""
    return await service.create_provider(data.dict(), current_user.id)

@admin_router.put("/providers/{provider_id}", response_model=OAuthProviderAdminResponse)
async def admin_update_provider(
    provider_id: UUID,
    data: OAuthProviderUpdate,
    current_user: User = Depends(get_current_admin),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Actualiza un proveedor OAuth."""
    return await service.update_provider(
        provider_id,
        data.dict(exclude_unset=True),
        current_user.id
    )

@admin_router.delete("/providers/{provider_id}")
async def admin_delete_provider(
    provider_id: UUID,
    current_user: User = Depends(get_current_admin),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """
    Elimina un proveedor OAuth.

    Solo permitido si no tiene usuarios vinculados.
    """
    await service.delete_provider(provider_id)
    return {"status": "deleted"}

@admin_router.post("/providers/{provider_id}/test", response_model=OAuthProviderTestResult)
async def admin_test_provider(
    provider_id: UUID,
    current_user: User = Depends(get_current_admin),
    service: OAuth2Service = Depends(get_oauth_service)
):
    """Prueba la configuración de un proveedor OAuth."""
    return await service.test_provider_config(provider_id)
```

---

## 5. Componentes Frontend

### 5.1 Componente de Login Social

```tsx
// components/auth/SocialLoginButtons.tsx

import React, { useEffect, useState } from 'react';
import { useOAuthProviders } from '@/hooks/useOAuthProviders';

interface OAuthProvider {
  code: string;
  name: string;
  icon_class?: string;
  button_text: string;
  button_color?: string;
  authorization_url: string;
}

interface SocialLoginButtonsProps {
  returnUrl?: string;
  mode: 'login' | 'signup' | 'link';
}

export const SocialLoginButtons: React.FC<SocialLoginButtonsProps> = ({
  returnUrl,
  mode
}) => {
  const { providers, loading, error } = useOAuthProviders(returnUrl);

  if (loading) {
    return <div className="social-login-loading">Cargando...</div>;
  }

  if (error || !providers?.length) {
    return null;
  }

  const handleOAuthClick = (provider: OAuthProvider) => {
    // Redirigir al proveedor OAuth
    window.location.href = provider.authorization_url;
  };

  return (
    <div className="social-login-buttons">
      {mode !== 'link' && (
        <div className="divider">
          <span>o continúa con</span>
        </div>
      )}

      <div className="providers-list">
        {providers.map((provider) => (
          <button
            key={provider.code}
            className="oauth-button"
            style={{ backgroundColor: provider.button_color }}
            onClick={() => handleOAuthClick(provider)}
          >
            {provider.icon_class && (
              <i className={`fa ${provider.icon_class}`} />
            )}
            <span>{provider.button_text}</span>
          </button>
        ))}
      </div>
    </div>
  );
};

// Hook para obtener proveedores
export const useOAuthProviders = (returnUrl?: string) => {
  const [providers, setProviders] = useState<OAuthProvider[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchProviders = async () => {
      try {
        const params = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : '';
        const response = await fetch(`/api/v1/auth/oauth/providers${params}`);

        if (!response.ok) throw new Error('Failed to fetch providers');

        const data = await response.json();
        setProviders(data.providers);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchProviders();
  }, [returnUrl]);

  return { providers, loading, error };
};
```

### 5.2 Componente de Gestión de Cuentas Vinculadas

```tsx
// components/settings/LinkedAccounts.tsx

import React, { useState } from 'react';
import { useLinkedAccounts, useUnlinkAccount, useLinkAccount } from '@/hooks/useOAuth';

export const LinkedAccounts: React.FC = () => {
  const { links, canUnlink, loading, refetch } = useLinkedAccounts();
  const { unlinkAccount, unlinking } = useUnlinkAccount();
  const { initiateLinking } = useLinkAccount();
  const [availableProviders, setAvailableProviders] = useState([]);

  // Proveedores disponibles para vincular
  const linkedCodes = new Set(links.map(l => l.provider_code));
  const unlinkableProviders = availableProviders.filter(
    p => !linkedCodes.has(p.code)
  );

  const handleUnlink = async (providerCode: string) => {
    if (!canUnlink) {
      alert('No puede desvincular su único método de autenticación');
      return;
    }

    if (confirm('¿Está seguro de desvincular esta cuenta?')) {
      await unlinkAccount(providerCode);
      refetch();
    }
  };

  const handleLink = async (providerCode: string) => {
    const { authorization_url } = await initiateLinking(providerCode);
    window.location.href = authorization_url;
  };

  return (
    <div className="linked-accounts">
      <h3>Cuentas vinculadas</h3>

      {/* Cuentas vinculadas */}
      <div className="linked-list">
        {links.map((link) => (
          <div key={link.id} className="linked-account">
            <div className="account-info">
              <span className="provider-name">{link.provider_name}</span>
              <span className="provider-email">{link.oauth_email}</span>
              <span className="login-count">
                {link.login_count} inicios de sesión
              </span>
            </div>

            <button
              onClick={() => handleUnlink(link.provider_code)}
              disabled={!canUnlink || unlinking}
              className="unlink-button"
            >
              Desvincular
            </button>
          </div>
        ))}
      </div>

      {/* Vincular nuevas cuentas */}
      {unlinkableProviders.length > 0 && (
        <div className="link-new">
          <h4>Vincular otra cuenta</h4>
          <div className="available-providers">
            {unlinkableProviders.map((provider) => (
              <button
                key={provider.code}
                onClick={() => handleLink(provider.code)}
                className="link-button"
              >
                <i className={`fa ${provider.icon_class}`} />
                Vincular {provider.name}
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};
```

---

## 6. Configuración de Proveedores

### 6.1 Google OAuth2

```markdown
## Configuración de Google OAuth2

### Paso 1: Crear proyecto en Google Cloud Console

1. Ir a https://console.cloud.google.com/
2. Crear nuevo proyecto o seleccionar existente
3. Ir a "APIs & Services" > "Credentials"

### Paso 2: Configurar pantalla de consentimiento

1. Ir a "OAuth consent screen"
2. Seleccionar tipo de usuario (Internal/External)
3. Completar información de la aplicación:
   - Nombre de la aplicación
   - Email de soporte
   - Logo (opcional)
4. Agregar scopes: openid, profile, email
5. Agregar dominios autorizados

### Paso 3: Crear credenciales OAuth

1. Ir a "Credentials" > "Create Credentials" > "OAuth client ID"
2. Tipo de aplicación: "Web application"
3. Nombre: "ERP Suite OAuth"
4. Orígenes autorizados: https://erp.empresa.com
5. URIs de redirección: https://erp.empresa.com/api/v1/auth/oauth/callback/google

### Paso 4: Configurar en ERP

Client ID: xxxx.apps.googleusercontent.com
Client Secret: GOCSPX-xxxx
```

### 6.2 Microsoft Azure AD

```markdown
## Configuración de Microsoft Azure AD

### Paso 1: Registrar aplicación en Azure Portal

1. Ir a https://portal.azure.com/
2. Ir a "Azure Active Directory" > "App registrations"
3. "New registration"
4. Nombre: "ERP Suite OAuth"
5. Tipos de cuenta: según necesidad (solo tenant, multitenant, etc.)
6. URI de redirección: Web > https://erp.empresa.com/api/v1/auth/oauth/callback/microsoft

### Paso 2: Configurar autenticación

1. Ir a "Authentication"
2. Agregar plataforma "Web"
3. Configurar logout URL si es necesario
4. Habilitar tokens: ID tokens, Access tokens

### Paso 3: Configurar API permissions

1. Ir a "API permissions"
2. Add permission > Microsoft Graph > Delegated
3. Seleccionar: openid, profile, email, User.Read

### Paso 4: Crear client secret

1. Ir a "Certificates & secrets"
2. "New client secret"
3. Descripción y expiración
4. Copiar valor (solo visible una vez)

### Paso 5: Configurar en ERP

Client ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Client Secret: xxxxxx~xxxxxxxxxxxxxxxxxxxxx
Tenant: common (o ID específico del tenant)
```

### 6.3 Validación de Configuración

```python
# services/oauth_config_validator.py

class OAuthConfigValidator:
    """Valida configuración de proveedores OAuth."""

    async def validate_provider_config(
        self,
        provider: 'OAuthProvider'
    ) -> Dict[str, Any]:
        """
        Realiza pruebas de conectividad y configuración.
        """
        results = {
            'success': True,
            'errors': [],
            'checks': {}
        }

        # 1. Verificar endpoint de autorización
        auth_check = await self._check_endpoint(
            provider.authorization_endpoint,
            'authorization'
        )
        results['checks']['authorization_endpoint'] = auth_check
        if not auth_check['reachable']:
            results['success'] = False
            results['errors'].append(auth_check['error'])

        # 2. Verificar endpoint de token
        token_check = await self._check_endpoint(
            provider.token_endpoint,
            'token',
            method='POST'
        )
        results['checks']['token_endpoint'] = token_check
        if not token_check['reachable']:
            results['success'] = False
            results['errors'].append(token_check['error'])

        # 3. Verificar endpoint de userinfo
        userinfo_check = await self._check_endpoint(
            provider.userinfo_endpoint,
            'userinfo'
        )
        results['checks']['userinfo_endpoint'] = userinfo_check
        if not userinfo_check['reachable']:
            results['success'] = False
            results['errors'].append(userinfo_check['error'])

        # 4. Verificar JWKS si está configurado
        if provider.jwks_uri:
            jwks_check = await self._check_jwks(provider.jwks_uri)
            results['checks']['jwks'] = jwks_check
            if not jwks_check['valid']:
                results['errors'].append(jwks_check['error'])

        return results

    async def _check_endpoint(
        self,
        url: str,
        name: str,
        method: str = 'GET'
    ) -> Dict[str, Any]:
        """Verifica que un endpoint sea alcanzable."""
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                if method == 'GET':
                    response = await client.get(url)
                else:
                    response = await client.post(url)

                # Para endpoints OAuth, 400/401 es esperado sin credenciales
                reachable = response.status_code in [200, 400, 401, 405]

                return {
                    'reachable': reachable,
                    'status_code': response.status_code,
                    'error': None if reachable else f"{name} retornó {response.status_code}"
                }

        except Exception as e:
            return {
                'reachable': False,
                'status_code': None,
                'error': f"No se pudo conectar a {name}: {str(e)}"
            }

    async def _check_jwks(self, url: str) -> Dict[str, Any]:
        """Verifica que JWKS sea válido."""
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                response = await client.get(url)

                if response.status_code != 200:
                    return {
                        'valid': False,
                        'error': f"JWKS retornó {response.status_code}"
                    }

                jwks = response.json()

                if 'keys' not in jwks or not jwks['keys']:
                    return {
                        'valid': False,
                        'error': "JWKS no contiene claves"
                    }

                return {
                    'valid': True,
                    'keys_count': len(jwks['keys']),
                    'error': None
                }

        except Exception as e:
            return {
                'valid': False,
                'error': f"Error al verificar JWKS: {str(e)}"
            }
```

---

## 7. Seguridad

### 7.1 Protecciones Implementadas

| Amenaza | Protección | Implementación |
|---------|------------|----------------|
| CSRF | State parameter | UUID único + validación en BD |
| Token interception | PKCE | code_verifier + S256 challenge |
| Token theft | Encriptación | AES-256 para tokens almacenados |
| Replay attacks | State consumido | Marcado como usado tras primer uso |
| Session hijacking | Cookies seguras | HttpOnly, Secure, SameSite |
| Domain phishing | Validación dominios | allowed_domains en proveedor |
| Token expiry | Validación tiempo | expires_at verificado |

### 7.2 Configuración de Seguridad

```python
OAUTH2_SECURITY_CONFIG = {
    # Estado OAuth
    'state_expiry_minutes': 10,
    'state_length': 32,

    # PKCE
    'pkce_enabled': True,
    'pkce_method': 'S256',
    'verifier_length': 64,

    # Tokens
    'encrypt_tokens': True,
    'token_encryption_algorithm': 'AES-256-GCM',

    # Cookies
    'cookie_httponly': True,
    'cookie_secure': True,  # Solo HTTPS
    'cookie_samesite': 'lax',

    # Validaciones
    'verify_email_domain': True,
    'require_email_verified': False,  # Algunos proveedores no lo soportan

    # Rate limiting
    'max_auth_attempts_per_minute': 10,
    'max_callback_attempts_per_minute': 5,
}
```

---

## 8. Eventos del Sistema

```python
# events/oauth_events.py

@dataclass
class OAuthLoginSuccessEvent:
    """Usuario autenticado via OAuth."""
    user_id: UUID
    provider_code: str
    oauth_uid: str
    is_new_user: bool
    ip_address: Optional[str]
    timestamp: datetime

@dataclass
class OAuthLoginFailedEvent:
    """Fallo en autenticación OAuth."""
    provider_code: str
    error_code: str
    error_description: Optional[str]
    ip_address: Optional[str]
    timestamp: datetime

@dataclass
class OAuthAccountLinkedEvent:
    """Cuenta OAuth vinculada a usuario."""
    user_id: UUID
    provider_code: str
    oauth_uid: str
    timestamp: datetime

@dataclass
class OAuthAccountUnlinkedEvent:
    """Cuenta OAuth desvinculada."""
    user_id: UUID
    provider_code: str
    timestamp: datetime
```

---

## 9. Integración con 2FA

```python
# Cuando OAuth es exitoso pero usuario tiene 2FA habilitado

class OAuth2FAIntegration:
    """Integración de OAuth con 2FA."""

    async def handle_oauth_with_2fa(
        self,
        user: 'User',
        oauth_result: Dict
    ) -> Dict:
        """
        Si el usuario tiene 2FA, requiere verificación adicional.
        """
        if not user.totp_enabled and not user.has_backup_codes:
            # Sin 2FA, autenticar directamente
            return await self._create_full_session(user, oauth_result)

        # Crear sesión parcial que requiere 2FA
        partial_session = await self.session_service.create_partial_session(
            user_id=user.id,
            auth_method='oauth',
            requires_2fa=True
        )

        return {
            'requires_2fa': True,
            'partial_session_token': partial_session.token,
            '2fa_methods': self._get_available_2fa_methods(user),
            'oauth_provider': oauth_result.get('provider')
        }

    def _get_available_2fa_methods(self, user: 'User') -> List[str]:
        """Retorna métodos 2FA disponibles para el usuario."""
        methods = []
        if user.totp_enabled:
            methods.append('totp')
        if user.has_backup_codes:
            methods.append('backup_code')
        return methods
```

---

## 10. Referencias

### 10.1 Odoo

- `addons/auth_oauth/models/auth_oauth.py`
- `addons/auth_oauth/controllers/main.py`

### 10.2 Estándares

- RFC 6749: OAuth 2.0 Authorization Framework
- RFC 7636: PKCE (Proof Key for Code Exchange)
- OpenID Connect Core 1.0

### 10.3 Documentos Relacionados

- SPEC-TWO-FACTOR-AUTHENTICATION
- SPEC-AUTENTICACION (módulo base)
- SPEC-SESIONES (gestión de sesiones)

---

## Historial de Cambios

| Versión | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0.0 | 2025-01-15 | AI Assistant | Versión inicial |
