ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
20 KiB
20 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-AUTH-001 | OAuth Providers Implementation | Specification | Done | RF-AUTH-001 | OQI-001 | 1.0 | 2025-12-05 | 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 Requerimiento: RF-AUTH-001
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
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
// 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
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,
});
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)
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
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
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
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
// 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
// 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
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
// 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
// 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
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
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
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);
});
});
});