'use strict'; const crypto = require('crypto'); const { google } = require('googleapis'); const { buildLetterText, PROVIDER_DATA } = require('../services/mailer'); const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA)); const STATE_MAX_BYTES = 4096; const SALT = () => process.env.SALT || 'resetea-oauth-default-insecure'; /* ── Elimina CRLF y tabuladores — previene header injection en RFC 2822 ── */ function sanitizeHeader(str, maxLen = 300) { return String(str || '').replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen); } /* ── Firma HMAC-SHA256 del state — previene state forgery en OAuth callback ── */ function signState(payload) { const nonce = crypto.randomBytes(16).toString('hex'); const data = { ...payload, _nonce: nonce }; const sig = crypto.createHmac('sha256', SALT()) .update(JSON.stringify(data)) .digest('hex'); return Buffer.from(JSON.stringify({ ...data, _sig: sig })).toString('base64url'); } function verifyState(raw) { if (!raw || typeof raw !== 'string') throw new Error('State ausente'); if (Buffer.byteLength(raw, 'utf8') > STATE_MAX_BYTES) throw new Error('State demasiado grande'); let obj; try { obj = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); } catch { throw new Error('State malformado'); } const { _sig, ...data } = obj; if (typeof _sig !== 'string' || _sig.length !== 64) throw new Error('Firma ausente o malformada'); const expected = crypto.createHmac('sha256', SALT()).update(JSON.stringify(data)).digest('hex'); const sigBuf = Buffer.from(_sig, 'hex'); const expectedBuf = Buffer.from(expected, 'hex'); if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) throw new Error('Firma inválida'); /* Devuelve payload sin los campos internos */ const { _nonce, ...params } = data; return params; } function getOAuth2Client() { return new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_REDIRECT_URI || 'https://resetea.net/api/gmail/callback' ); } /* ── GET /api/gmail/auth ─────────────────────────────────────────── */ exports.authInit = (req, res) => { const { provider, name, email, nickname, phone, address, extra, requestType } = req.query; if (!provider || !email || !name) return res.status(400).json({ error: 'Faltan parámetros obligatorios (provider, name, email).' }); if (!ALLOWED_PROVIDERS.has(provider)) return res.status(400).json({ error: 'Proveedor no soportado.' }); /* Sanitizamos los campos que luego irán a cabeceras de email */ const state = signState({ provider, name: sanitizeHeader(name, 200), email: sanitizeHeader(email, 200), nickname: sanitizeHeader(nickname, 100), phone: sanitizeHeader(phone, 30), address: sanitizeHeader(address, 300), extra: sanitizeHeader(extra, 500), requestType: sanitizeHeader(requestType, 20), }); const oauth2Client = getOAuth2Client(); const authUrl = oauth2Client.generateAuthUrl({ access_type: 'online', scope: ['https://www.googleapis.com/auth/gmail.send'], prompt: 'consent', state, }); res.redirect(authUrl); }; /* ── GET /api/gmail/callback ─────────────────────────────────────── */ exports.authCallback = async (req, res) => { const { code, state, error } = req.query; if (error) return res.redirect('/plantillas.html?oauth=cancelled'); if (!code || !state) return res.status(400).send('Parámetros OAuth inválidos.'); let params; try { params = verifyState(state); } catch (e) { console.warn('OAuth state rejection:', e.message); return res.status(400).send('State OAuth inválido o expirado.'); } /* Validar provider contra whitelist (por si el state fue manipulado antes de que implementáramos la firma, o si la firma falla silenciosamente en el futuro) */ if (!ALLOWED_PROVIDERS.has(params.provider)) { return res.status(400).send('Proveedor no soportado.'); } try { const oauth2Client = getOAuth2Client(); const { tokens } = await oauth2Client.getToken(code); oauth2Client.setCredentials(tokens); const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); const providerInfo = PROVIDER_DATA[params.provider]; if (!providerInfo.email) { return res.redirect( `/plantillas.html?oauth=no_email&provider=${encodeURIComponent(params.provider)}` + `&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}` ); } const letterText = buildLetterText({ providerInfo, senderName: params.name, senderEmail: params.email, senderNick: params.nickname || '', senderPhone: params.phone || '', senderAddress: params.address || '', extra: params.extra || '', }); /* Cabeceras RFC 2822 — name y email ya vienen sanitizados desde authInit */ const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`; const rawEmail = [ `From: ${params.name} <${params.email}>`, `To: ${providerInfo.email}`, `Subject: ${subject}`, `MIME-Version: 1.0`, `Content-Type: text/plain; charset=UTF-8`, '', letterText, ].join('\r\n'); await gmail.users.messages.send({ userId: 'me', requestBody: { raw: Buffer.from(rawEmail).toString('base64url') }, }); res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`); } catch (err) { console.error('Gmail OAuth error:', err.message); res.redirect('/plantillas.html?oauth=error'); } };