--- id: "ET-AUTH-001" title: "OAuth Providers Implementation" type: "Specification" status: "Done" rf_parent: "RF-AUTH-001" epic: "OQI-001" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # 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 Trading Platform. --- ## 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; async getGoogleUserInfo(code: string): Promise; // Facebook OAuth async getFacebookAuthUrl(state: string): Promise; async getFacebookUserInfo(code: string): Promise; // Twitter OAuth 2.0 async getTwitterAuthUrl(state: string, codeChallenge: string): Promise; async getTwitterUserInfo(code: string, codeVerifier: string): Promise; // Apple Sign In async getAppleAuthUrl(state: string): Promise; async getAppleUserInfo(code: string): Promise; // GitHub OAuth async getGitHubAuthUrl(state: string): Promise; async getGitHubUserInfo(code: string): Promise; } ``` ### 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 { // 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 = ({ 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 (
handleOAuthLogin('google')} /> handleOAuthLogin('facebook')} /> handleOAuthLogin('twitter')} /> handleOAuthLogin('apple')} /> handleOAuthLogin('github')} />
); }; ``` ### 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 ; }; ``` --- ## 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)