# ET-AUTH-001: Especificación Técnica - OAuth Providers

**Version:** 1.0.0
**Fecha:** 2025-12-05
**Estado:** ✅ Implementado
**Épica:** [OQI-001](../_MAP.md)
**Requerimiento:** [RF-AUTH-001](../requerimientos/RF-AUTH-001-oauth.md)

---

## Resumen

Esta especificación detalla la implementación técnica del sistema de autenticación OAuth 2.0 multi-proveedor para OrbiQuant IA.

---

## Arquitectura

```
┌─────────────────────────────────────────────────────────────────────────┐
│                              FRONTEND                                    │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐     │
│  │ SocialLogin     │    │ AuthCallback    │    │ AuthStore       │     │
│  │ Buttons.tsx     │───▶│ Page.tsx        │───▶│ (Zustand)       │     │
│  └─────────────────┘    └─────────────────┘    └─────────────────┘     │
└────────────────────────────────┬────────────────────────────────────────┘
                                 │
                                 │ HTTPS
                                 ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                              BACKEND                                     │
│  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐     │
│  │ auth.routes.ts  │───▶│ oauth.service   │───▶│ token.service   │     │
│  │                 │    │ .ts             │    │ .ts             │     │
│  └─────────────────┘    └─────────────────┘    └─────────────────┘     │
│           │                     │                      │                │
│           │                     │                      │                │
│           ▼                     ▼                      ▼                │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                     PostgreSQL (public schema)                   │   │
│  │  ┌──────────┐  ┌────────────────┐  ┌──────────────────────┐    │   │
│  │  │  users   │  │ oauth_accounts │  │      sessions        │    │   │
│  │  └──────────┘  └────────────────┘  └──────────────────────┘    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘
                                 │
                                 ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         OAUTH PROVIDERS                                  │
│  ┌─────────┐  ┌──────────┐  ┌─────────┐  ┌─────────┐  ┌──────────┐    │
│  │ Google  │  │ Facebook │  │ Twitter │  │  Apple  │  │  GitHub  │    │
│  └─────────┘  └──────────┘  └─────────┘  └─────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────────────────┘
```

---

## Componentes

### 1. OAuth Service (`oauth.service.ts`)

**Ubicación:** `apps/backend/src/modules/auth/services/oauth.service.ts`

```typescript
import { google } from 'googleapis';
import axios from 'axios';

export interface OAuthUserInfo {
  provider: AuthProvider;
  providerId: string;
  email: string | null;
  emailVerified: boolean;
  firstName: string;
  lastName: string;
  avatarUrl: string | null;
  accessToken: string;
  refreshToken?: string;
}

export class OAuthService {
  // Google OAuth
  async getGoogleAuthUrl(state: string): Promise<string>;
  async getGoogleUserInfo(code: string): Promise<OAuthUserInfo>;

  // Facebook OAuth
  async getFacebookAuthUrl(state: string): Promise<string>;
  async getFacebookUserInfo(code: string): Promise<OAuthUserInfo>;

  // Twitter OAuth 2.0
  async getTwitterAuthUrl(state: string, codeChallenge: string): Promise<string>;
  async getTwitterUserInfo(code: string, codeVerifier: string): Promise<OAuthUserInfo>;

  // Apple Sign In
  async getAppleAuthUrl(state: string): Promise<string>;
  async getAppleUserInfo(code: string): Promise<OAuthUserInfo>;

  // GitHub OAuth
  async getGitHubAuthUrl(state: string): Promise<string>;
  async getGitHubUserInfo(code: string): Promise<OAuthUserInfo>;
}
```

### 2. OAuth Routes

**Ubicación:** `apps/backend/src/modules/auth/auth.routes.ts`

```typescript
// Get authorization URL
router.get('/oauth/:provider/url', authController.getOAuthUrl);

// Exchange code for tokens
router.post('/oauth/:provider', authController.handleOAuthCallback);

// Unlink OAuth provider
router.delete('/oauth/:provider', authenticate, authController.unlinkOAuthProvider);

// List linked providers
router.get('/oauth/providers', authenticate, authController.getLinkedProviders);
```

---

## Configuración por Proveedor

### Google

```typescript
const googleConfig = {
  clientId: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  redirectUri: `${process.env.API_URL}/auth/oauth/google/callback`,
  scopes: ['profile', 'email'],
  accessType: 'offline',
  prompt: 'consent',
};

// Authorization URL
const oauth2Client = new google.auth.OAuth2(
  googleConfig.clientId,
  googleConfig.clientSecret,
  googleConfig.redirectUri
);

const authUrl = oauth2Client.generateAuthUrl({
  access_type: googleConfig.accessType,
  scope: googleConfig.scopes,
  state: stateToken,
  prompt: googleConfig.prompt,
});
```

### Facebook

```typescript
const facebookConfig = {
  clientId: process.env.FACEBOOK_APP_ID,
  clientSecret: process.env.FACEBOOK_APP_SECRET,
  redirectUri: `${process.env.API_URL}/auth/oauth/facebook/callback`,
  scopes: ['email', 'public_profile'],
};

// Authorization URL
const authUrl = `https://www.facebook.com/v18.0/dialog/oauth?` +
  `client_id=${facebookConfig.clientId}` +
  `&redirect_uri=${encodeURIComponent(facebookConfig.redirectUri)}` +
  `&scope=${facebookConfig.scopes.join(',')}` +
  `&state=${stateToken}`;
```

### X/Twitter (OAuth 2.0 with PKCE)

```typescript
const twitterConfig = {
  clientId: process.env.TWITTER_CLIENT_ID,
  clientSecret: process.env.TWITTER_CLIENT_SECRET,
  redirectUri: `${process.env.API_URL}/auth/oauth/twitter/callback`,
  scopes: ['tweet.read', 'users.read', 'offline.access'],
};

// PKCE challenge
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// Authorization URL
const authUrl = `https://twitter.com/i/oauth2/authorize?` +
  `response_type=code` +
  `&client_id=${twitterConfig.clientId}` +
  `&redirect_uri=${encodeURIComponent(twitterConfig.redirectUri)}` +
  `&scope=${encodeURIComponent(twitterConfig.scopes.join(' '))}` +
  `&state=${stateToken}` +
  `&code_challenge=${codeChallenge}` +
  `&code_challenge_method=S256`;
```

### Apple Sign In

```typescript
const appleConfig = {
  clientId: process.env.APPLE_CLIENT_ID, // Service ID
  teamId: process.env.APPLE_TEAM_ID,
  keyId: process.env.APPLE_KEY_ID,
  privateKey: process.env.APPLE_PRIVATE_KEY,
  redirectUri: `${process.env.API_URL}/auth/oauth/apple/callback`,
  scopes: ['name', 'email'],
};

// Generate client secret (JWT)
function generateAppleClientSecret(): string {
  return jwt.sign({}, appleConfig.privateKey, {
    algorithm: 'ES256',
    expiresIn: '180d',
    audience: 'https://appleid.apple.com',
    issuer: appleConfig.teamId,
    subject: appleConfig.clientId,
    keyid: appleConfig.keyId,
  });
}
```

### GitHub

```typescript
const githubConfig = {
  clientId: process.env.GITHUB_CLIENT_ID,
  clientSecret: process.env.GITHUB_CLIENT_SECRET,
  redirectUri: `${process.env.API_URL}/auth/oauth/github/callback`,
  scopes: ['read:user', 'user:email'],
};

// Authorization URL
const authUrl = `https://github.com/login/oauth/authorize?` +
  `client_id=${githubConfig.clientId}` +
  `&redirect_uri=${encodeURIComponent(githubConfig.redirectUri)}` +
  `&scope=${encodeURIComponent(githubConfig.scopes.join(' '))}` +
  `&state=${stateToken}`;
```

---

## Esquema de Base de Datos

### Tabla: oauth_accounts

```sql
CREATE TABLE oauth_accounts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    provider auth_provider_enum NOT NULL,
    provider_user_id VARCHAR(255) NOT NULL,
    email VARCHAR(255),
    access_token TEXT,          -- Encriptado
    refresh_token TEXT,         -- Encriptado
    token_expires_at TIMESTAMPTZ,
    raw_profile JSONB,          -- Perfil completo del proveedor
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE(provider, provider_user_id),
    UNIQUE(user_id, provider)
);

-- ENUM para proveedores
CREATE TYPE auth_provider_enum AS ENUM (
    'google',
    'facebook',
    'twitter',
    'apple',
    'github'
);

-- Índices
CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id);
CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider);
CREATE INDEX idx_oauth_accounts_email ON oauth_accounts(email);
```

---

## Flujo de Implementación

### 1. Obtener URL de Autorización

```typescript
// GET /auth/oauth/:provider/url
async getOAuthUrl(req: Request, res: Response) {
  const { provider } = req.params;
  const { redirectTo } = req.query;

  // Generar state con información de redirect
  const state = await this.generateState({
    provider,
    redirectTo: redirectTo || '/dashboard',
    timestamp: Date.now(),
  });

  // Almacenar state en Redis (10 min TTL)
  await redis.setEx(`oauth:state:${state}`, 600, JSON.stringify({
    provider,
    redirectTo,
    createdAt: Date.now(),
  }));

  let authUrl: string;
  let codeVerifier: string | null = null;

  switch (provider) {
    case 'google':
      authUrl = await this.oauthService.getGoogleAuthUrl(state);
      break;
    case 'twitter':
      codeVerifier = generateCodeVerifier();
      await redis.setEx(`oauth:pkce:${state}`, 600, codeVerifier);
      authUrl = await this.oauthService.getTwitterAuthUrl(state, codeVerifier);
      break;
    // ... otros proveedores
  }

  res.json({ authUrl, state });
}
```

### 2. Manejar Callback

```typescript
// POST /auth/oauth/:provider
async handleOAuthCallback(req: Request, res: Response) {
  const { provider } = req.params;
  const { code, state } = req.body;

  // Validar state
  const stateData = await redis.get(`oauth:state:${state}`);
  if (!stateData) {
    throw new UnauthorizedError('Invalid or expired state');
  }
  await redis.del(`oauth:state:${state}`);

  // Obtener user info del proveedor
  let userInfo: OAuthUserInfo;

  switch (provider) {
    case 'google':
      userInfo = await this.oauthService.getGoogleUserInfo(code);
      break;
    case 'twitter':
      const codeVerifier = await redis.get(`oauth:pkce:${state}`);
      userInfo = await this.oauthService.getTwitterUserInfo(code, codeVerifier);
      await redis.del(`oauth:pkce:${state}`);
      break;
    // ... otros proveedores
  }

  // Buscar o crear usuario
  const user = await this.findOrCreateUser(userInfo);

  // Generar tokens JWT
  const tokens = await this.tokenService.generateTokens(user);

  res.json({
    user: sanitizeUser(user),
    tokens,
    redirectTo: JSON.parse(stateData).redirectTo,
  });
}
```

### 3. Buscar o Crear Usuario

```typescript
async findOrCreateUser(oauthInfo: OAuthUserInfo): Promise<User> {
  // Buscar cuenta OAuth existente
  let oauthAccount = await db.query(`
    SELECT * FROM oauth_accounts
    WHERE provider = $1 AND provider_user_id = $2
  `, [oauthInfo.provider, oauthInfo.providerId]);

  if (oauthAccount.rows[0]) {
    // Usuario existente - actualizar tokens
    await this.updateOAuthTokens(oauthAccount.rows[0].id, oauthInfo);
    return this.userService.findById(oauthAccount.rows[0].user_id);
  }

  // Buscar usuario por email (si disponible)
  if (oauthInfo.email) {
    const existingUser = await this.userService.findByEmail(oauthInfo.email);
    if (existingUser) {
      // Vincular OAuth a usuario existente
      await this.linkOAuthAccount(existingUser.id, oauthInfo);
      return existingUser;
    }
  }

  // Crear nuevo usuario
  const newUser = await this.userService.create({
    email: oauthInfo.email,
    firstName: oauthInfo.firstName,
    lastName: oauthInfo.lastName,
    avatarUrl: oauthInfo.avatarUrl,
    emailVerified: oauthInfo.emailVerified,
    status: oauthInfo.emailVerified ? 'active' : 'pending_verification',
  });

  // Vincular cuenta OAuth
  await this.linkOAuthAccount(newUser.id, oauthInfo);

  return newUser;
}
```

---

## Componentes Frontend

### SocialLoginButtons

```typescript
// apps/frontend/src/modules/auth/components/SocialLoginButtons.tsx
export const SocialLoginButtons: React.FC<Props> = ({ mode, onStart, onSuccess, onError }) => {
  const handleOAuthLogin = async (provider: OAuthProvider) => {
    onStart?.();

    try {
      // Obtener URL de autorización
      const { data } = await api.get(`/auth/oauth/${provider}/url`, {
        params: { redirectTo: window.location.pathname },
      });

      // Guardar state para verificación
      sessionStorage.setItem('oauth_state', data.state);

      // Redirigir a proveedor
      window.location.href = data.authUrl;
    } catch (error) {
      onError?.(error);
    }
  };

  return (
    <div className="space-y-3">
      <OAuthButton provider="google" onClick={() => handleOAuthLogin('google')} />
      <OAuthButton provider="facebook" onClick={() => handleOAuthLogin('facebook')} />
      <OAuthButton provider="twitter" onClick={() => handleOAuthLogin('twitter')} />
      <OAuthButton provider="apple" onClick={() => handleOAuthLogin('apple')} />
      <OAuthButton provider="github" onClick={() => handleOAuthLogin('github')} />
    </div>
  );
};
```

### AuthCallback Page

```typescript
// apps/frontend/src/modules/auth/pages/AuthCallback.tsx
export const AuthCallback: React.FC = () => {
  const navigate = useNavigate();
  const { setUser, setTokens } = useAuthStore();

  useEffect(() => {
    const handleCallback = async () => {
      const params = new URLSearchParams(window.location.search);
      const code = params.get('code');
      const state = params.get('state');
      const provider = params.get('provider') || extractProvider(pathname);

      // Validar state
      const savedState = sessionStorage.getItem('oauth_state');
      if (state !== savedState) {
        navigate('/login?error=invalid_state');
        return;
      }
      sessionStorage.removeItem('oauth_state');

      try {
        const { data } = await api.post(`/auth/oauth/${provider}`, {
          code,
          state,
        });

        setUser(data.user);
        setTokens(data.tokens);
        navigate(data.redirectTo || '/dashboard');
      } catch (error) {
        navigate(`/login?error=${error.code}`);
      }
    };

    handleCallback();
  }, []);

  return <LoadingSpinner message="Completando inicio de sesión..." />;
};
```

---

## Seguridad

### Encriptación de Tokens

```typescript
import crypto from 'crypto';

const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32 bytes
const IV_LENGTH = 16;

export function encryptToken(token: string): string {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);

  let encrypted = cipher.update(token, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}

export function decryptToken(encrypted: string): string {
  const [ivHex, authTagHex, encryptedData] = encrypted.split(':');

  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(authTagHex, 'hex');

  const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}
```

### PKCE para OAuth 2.0

```typescript
import crypto from 'crypto';

export function generateCodeVerifier(): string {
  return crypto.randomBytes(32).toString('base64url');
}

export function generateCodeChallenge(verifier: string): string {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}
```

---

## Testing

### Unit Tests

```typescript
describe('OAuthService', () => {
  describe('getGoogleUserInfo', () => {
    it('should return user info from Google', async () => {
      const mockCode = 'test_code';

      // Mock Google API response
      nock('https://oauth2.googleapis.com')
        .post('/token')
        .reply(200, { access_token: 'test_token' });

      nock('https://www.googleapis.com')
        .get('/oauth2/v2/userinfo')
        .reply(200, {
          id: '12345',
          email: 'test@gmail.com',
          verified_email: true,
          given_name: 'Test',
          family_name: 'User',
          picture: 'https://...',
        });

      const result = await oauthService.getGoogleUserInfo(mockCode);

      expect(result.provider).toBe('google');
      expect(result.email).toBe('test@gmail.com');
      expect(result.emailVerified).toBe(true);
    });
  });
});
```

---

## Referencias

- [Google OAuth 2.0 Documentation](https://developers.google.com/identity/protocols/oauth2)
- [Facebook Login Documentation](https://developers.facebook.com/docs/facebook-login)
- [Twitter OAuth 2.0 Documentation](https://developer.twitter.com/en/docs/authentication/oauth-2-0)
- [Sign in with Apple Documentation](https://developer.apple.com/documentation/sign_in_with_apple)
- [GitHub OAuth Apps Documentation](https://docs.github.com/en/apps/oauth-apps)
