workspace/projects/gamilit/docs/01-fase-alcance-inicial/EAI-001-fundamentos/especificaciones/ET-AUTH-003-oauth.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

19 KiB

ET-AUTH-003: OAuth 2.0 Providers

📋 Metadata

Campo Valor
ID ET-AUTH-003
Módulo Autenticación y Autorización
Tipo Especificación Técnica
Estado Implementado
Versión 1.0
Fecha creación 2025-11-07

🔗 Referencias

Requerimiento Funcional

📄 RF-AUTH-003: Proveedores de Autenticación OAuth

Implementación DDL

🗄️ ENUM Principal:

-- apps/database/ddl/00-prerequisites.sql:38-40
DO $$ BEGIN
    CREATE TYPE public.auth_provider AS ENUM (
        'local',     -- Email/password tradicional
        'google',    -- Google OAuth 2.0
        'facebook',  -- Facebook Login
        'apple',     -- Apple Sign In
        'microsoft', -- Microsoft Account
        'github'     -- GitHub OAuth
    );
EXCEPTION WHEN duplicate_object THEN null; END $$;

Tabla de Configuración:

-- apps/database/ddl/schemas/auth_management/tables/05-auth_providers.sql
CREATE TABLE auth_management.auth_providers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    provider_name public.auth_provider NOT NULL UNIQUE,
    is_enabled BOOLEAN NOT NULL DEFAULT true,
    client_id TEXT, -- Almacenado encriptado
    client_secret_encrypted TEXT,
    config JSONB, -- Configuración específica del provider
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Tabla de Vinculación:

-- apps/database/ddl/schemas/auth_management/tables/06-linked_providers.sql
CREATE TABLE auth_management.linked_providers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth_management.profiles(user_id) ON DELETE CASCADE,
    provider public.auth_provider NOT NULL,
    provider_user_id TEXT NOT NULL, -- ID del usuario en el provider externo
    provider_email TEXT,
    provider_data JSONB, -- Datos adicionales del provider
    linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

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

CREATE INDEX idx_linked_providers_user ON auth_management.linked_providers(user_id);
CREATE INDEX idx_linked_providers_provider ON auth_management.linked_providers(provider, provider_user_id);

🏗️ Arquitectura OAuth

┌─────────────────────────────────────────────────────────────────┐
│                        USUARIO FRONTEND                          │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Login Page                                             │    │
│  │  [Continue with Google] [Continue with Facebook] [...] │    │
│  └───────────────────┬────────────────────────────────────┘    │
└────────────────────────┼────────────────────────────────────────┘
                         │ Click
                         ▼
┌─────────────────────────────────────────────────────────────────┐
│                     BACKEND GAMILIT                              │
│  GET /auth/{provider}                                            │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  1. Generar state (CSRF protection)                  │      │
│  │  2. Almacenar state en Redis (5 min TTL)             │      │
│  │  3. Construir authorization URL                      │      │
│  │  4. Redirigir a provider                             │      │
│  └────────────────┬─────────────────────────────────────┘      │
└─────────────────────┼────────────────────────────────────────────┘
                      │ Redirect
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                   OAUTH PROVIDER (Google, etc.)                  │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  Consent Screen                                       │      │
│  │  "Gamilit wants to access:"                          │      │
│  │  ✓ Your email                                        │      │
│  │  ✓ Your name                                         │      │
│  │  ✓ Your profile picture                              │      │
│  │                                                        │      │
│  │  [Allow]  [Cancel]                                   │      │
│  └────────────────┬─────────────────────────────────────┘      │
└─────────────────────┼────────────────────────────────────────────┘
                      │ User clicks Allow
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│              OAUTH PROVIDER → BACKEND CALLBACK                   │
│  GET /auth/{provider}/callback?code=abc123&state=xyz            │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  1. Validar state (CSRF check)                       │      │
│  │  2. Intercambiar code por access_token               │      │
│  │     POST to provider token endpoint                  │      │
│  │  3. Obtener perfil de usuario                        │      │
│  │     GET to provider userinfo endpoint                │      │
│  │  4. Buscar/crear usuario en DB                       │      │
│  │  5. Generar JWT                                      │      │
│  │  6. Redirigir a frontend con token                   │      │
│  └────────────────┬─────────────────────────────────────┘      │
└─────────────────────┼────────────────────────────────────────────┘
                      │ Redirect
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                    FRONTEND CALLBACK                             │
│  /auth/callback?token=jwt_token                                 │
│  ┌──────────────────────────────────────────────────────┐      │
│  │  1. Extraer JWT de query params                      │      │
│  │  2. Almacenar en localStorage                        │      │
│  │  3. Configurar axios headers                         │      │
│  │  4. Redirigir a dashboard                            │      │
│  └──────────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────────┘

🔧 Implementación por Provider

1. Google OAuth 2.0

Configuración

// apps/backend/src/config/oauth.config.ts
export const googleConfig = {
  clientID: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  callbackURL: `${process.env.API_URL}/auth/google/callback`,
  scope: ['profile', 'email'],
};

Strategy (Passport.js)

// apps/backend/src/modules/auth/strategies/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from '../services/auth.service';
import { googleConfig } from '@/config/oauth.config';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(private authService: AuthService) {
    super(googleConfig);
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback,
  ): Promise<any> {
    const { id, emails, displayName, photos } = profile;

    const user = await this.authService.findOrCreateOAuthUser({
      provider: 'google',
      providerId: id,
      email: emails[0].value,
      name: displayName,
      picture: photos[0]?.value,
    });

    done(null, user);
  }
}

Endpoints

// apps/backend/src/modules/auth/controllers/oauth.controller.ts
@Controller('auth')
export class OAuthController {
  @Get('google')
  @UseGuards(AuthGuard('google'))
  async googleLogin() {
    // Passport maneja la redirección
  }

  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  async googleCallback(@Req() req, @Res() res) {
    const jwt = await this.authService.generateJWT(req.user);
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${jwt}`);
  }
}

2. Apple Sign In

Peculiaridades de Apple

1. Configuración más compleja:

// apps/backend/src/config/oauth.config.ts
import * as fs from 'fs';

export const appleConfig = {
  clientID: process.env.APPLE_SERVICE_ID!, // Service ID
  teamID: process.env.APPLE_TEAM_ID!,
  keyID: process.env.APPLE_KEY_ID!,
  privateKey: fs.readFileSync(process.env.APPLE_PRIVATE_KEY_PATH!, 'utf8'),
  callbackURL: `${process.env.API_URL}/auth/apple/callback`,
  scope: ['name', 'email'],
  passReqToCallback: true,
};

2. Datos solo en primer login:

// apps/backend/src/modules/auth/strategies/apple.strategy.ts
@Injectable()
export class AppleStrategy extends PassportStrategy(Strategy, 'apple') {
  async validate(
    req: Request,
    accessToken: string,
    refreshToken: string,
    idToken: any,
    profile: any,
    done: VerifyCallback,
  ): Promise<any> {
    const { sub: appleId } = idToken;

    // Apple solo envía user info en PRIMER login
    const userInfo = req.body?.user
      ? JSON.parse(req.body.user)
      : null;

    // Buscar usuario existente
    let user = await this.authService.findUserByProvider('apple', appleId);

    if (!user) {
      // Primer login - crear usuario con info de Apple
      if (!userInfo) {
        throw new BadRequestException('User info not provided by Apple');
      }

      user = await this.authService.createOAuthUser({
        provider: 'apple',
        providerId: appleId,
        email: userInfo.email,
        name: `${userInfo.name.firstName} ${userInfo.name.lastName}`,
      });
    }

    // Logins subsecuentes - solo tenemos appleId
    done(null, user);
  }
}

3. Email Relay:

// Manejar relay emails de Apple
if (email.includes('@privaterelay.appleid.com')) {
  // Es un relay email - almacenar como email real del usuario
  // Emails enviados a este relay se reenviarán al usuario
  user.email = email;
  user.is_relay_email = true;
}

3. Microsoft Account

// apps/backend/src/modules/auth/strategies/microsoft.strategy.ts
import { Strategy } from 'passport-microsoft';

export const microsoftConfig = {
  clientID: process.env.MICROSOFT_CLIENT_ID!,
  clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
  callbackURL: `${process.env.API_URL}/auth/microsoft/callback`,
  scope: ['user.read'],
  tenant: 'common', // Acepta cuentas personales Y organizacionales
};

@Injectable()
export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
  async validate(accessToken: string, refreshToken: string, profile: any) {
    const { oid, mail, displayName, photos } = profile;

    return await this.authService.findOrCreateOAuthUser({
      provider: 'microsoft',
      providerId: oid,
      email: mail,
      name: displayName,
      picture: photos[0]?.value,
    });
  }
}

🔐 Seguridad

1. State Parameter (CSRF Protection)

// apps/backend/src/modules/auth/services/oauth-state.service.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from '@/modules/redis/redis.service';
import { randomBytes } from 'crypto';

@Injectable()
export class OAuthStateService {
  constructor(private redis: RedisService) {}

  /**
   * Genera y almacena state para CSRF protection
   */
  async generateState(userId?: string): Promise<string> {
    const state = randomBytes(32).toString('hex');

    await this.redis.set(
      `oauth:state:${state}`,
      JSON.stringify({ userId, createdAt: Date.now() }),
      'EX',
      300, // 5 minutos
    );

    return state;
  }

  /**
   * Valida state recibido del provider
   */
  async validateState(state: string): Promise<boolean> {
    const data = await this.redis.get(`oauth:state:${state}`);

    if (!data) {
      return false;
    }

    // Eliminar state (one-time use)
    await this.redis.del(`oauth:state:${state}`);

    return true;
  }
}

2. Validación de Tokens

// apps/backend/src/modules/auth/services/oauth-validation.service.ts
@Injectable()
export class OAuthValidationService {
  /**
   * Valida token de Google con tokeninfo endpoint
   */
  async validateGoogleToken(accessToken: string): Promise<boolean> {
    try {
      const response = await fetch(
        `https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`
      );

      if (!response.ok) {
        return false;
      }

      const data = await response.json();

      // Validar que el token es para nuestra app
      if (data.aud !== process.env.GOOGLE_CLIENT_ID) {
        return false;
      }

      return true;
    } catch (error) {
      return false;
    }
  }

  /**
   * Valida JWT de Apple
   */
  async validateAppleJWT(idToken: string): Promise<any> {
    const jwksClient = require('jwks-rsa');
    const jwt = require('jsonwebtoken');

    const client = jwksClient({
      jwksUri: 'https://appleid.apple.com/auth/keys',
    });

    const getKey = (header, callback) => {
      client.getSigningKey(header.kid, (err, key) => {
        callback(err, key?.getPublicKey());
      });
    };

    return new Promise((resolve, reject) => {
      jwt.verify(idToken, getKey, {
        issuer: 'https://appleid.apple.com',
        audience: process.env.APPLE_SERVICE_ID,
      }, (err, decoded) => {
        if (err) reject(err);
        else resolve(decoded);
      });
    });
  }
}

3. Rate Limiting

// Prevenir abuse de OAuth endpoints
@UseGuards(ThrottlerGuard)
@Throttle(5, 60) // 5 intentos por minuto
@Get('auth/:provider')
async oauthLogin(@Param('provider') provider: string) {
  // ...
}

@UseGuards(ThrottlerGuard)
@Throttle(10, 60) // 10 callbacks por minuto
@Get('auth/:provider/callback')
async oauthCallback(@Param('provider') provider: string) {
  // ...
}

📊 Métricas y Monitoreo

Eventos a Trackear

// apps/backend/src/modules/auth/services/oauth-metrics.service.ts
@Injectable()
export class OAuthMetricsService {
  async trackOAuthEvent(event: string, provider: string, metadata?: any) {
    await this.metricsService.increment(`oauth.${event}`, {
      provider,
      ...metadata,
    });
  }

  // Eventos:
  // - oauth.login_initiated
  // - oauth.login_success
  // - oauth.login_failed
  // - oauth.account_linked
  // - oauth.token_validation_failed
}

Dashboard Metrics

Métricas importantes para monitorear:

  • Success Rate por provider (%)
  • Average Response Time del flujo OAuth
  • Error Rate por provider
  • Token Validation Failures
  • Account Linking Rate

🧪 Testing

Test Case: Login con Google exitoso

test('User can login with Google OAuth', async () => {
  // Mock Google OAuth
  mockPassportStrategy('google', {
    id: 'google_123',
    emails: [{ value: 'test@gmail.com' }],
    displayName: 'Test User',
    photos: [{ value: 'https://photo.url' }],
  });

  // Iniciar flujo
  const loginResponse = await request(app.getHttpServer())
    .get('/auth/google')
    .expect(302); // Redirect

  // Simular callback
  const callbackResponse = await request(app.getHttpServer())
    .get('/auth/google/callback')
    .query({ code: 'mock_code', state: 'valid_state' })
    .expect(302); // Redirect to frontend

  // Verificar JWT en redirect
  const redirectUrl = callbackResponse.headers.location;
  expect(redirectUrl).toContain('/auth/callback?token=');

  // Extraer y validar JWT
  const token = new URL(redirectUrl).searchParams.get('token');
  const decoded = jwt.verify(token, process.env.JWT_SECRET);

  expect(decoded.sub).toBeDefined();
  expect(decoded.email).toBe('test@gmail.com');
});

📚 Referencias

Documentos Relacionados

Documentación Oficial


Documento: docs/02-especificaciones-tecnicas/01-autenticacion-autorizacion/ET-AUTH-003-oauth.md Ruta relativa desde docs/: 02-especificaciones-tecnicas/01-autenticacion-autorizacion/ET-AUTH-003-oauth.md