106 lines
5.4 KiB
JavaScript
106 lines
5.4 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { initializeApp, getApps } from 'firebase-admin/app';
|
|
import { getAuth } from 'firebase-admin/auth';
|
|
import { requireWebsiteQaIsolation } from '../../tools/website-qa-isolation.mjs';
|
|
|
|
const projectId = process.env.FIREBASE_PROJECT_ID;
|
|
const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
|
|
const port = Number(process.env.QA_AUTH_BACKEND_PORT ?? 8189);
|
|
if (process.env.QA_FOCUSED_WEBSITE === 'true') requireWebsiteQaIsolation();
|
|
if (!projectId?.startsWith('demo-') || !authEmulatorHost?.startsWith('127.0.0.1:')) {
|
|
throw new Error('QA auth backend requires a demo project and loopback Auth emulator.');
|
|
}
|
|
|
|
const app = getApps()[0] ?? initializeApp({ projectId });
|
|
const auth = getAuth(app);
|
|
const sessions = new Map();
|
|
const deviceSessions = new Map();
|
|
const sessionCount = Number(process.env.QA_DEVICE_SESSION_COUNT ?? 1);
|
|
if (!Number.isInteger(sessionCount) || sessionCount < 1 || sessionCount > 100) throw new Error('Invalid fixture session count');
|
|
|
|
function json(response, status, body) {
|
|
response.writeHead(status, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify(body));
|
|
}
|
|
|
|
const server = createServer(async (request, response) => {
|
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
if (url.pathname === '/api/v1/auth/firebase/login' && request.method === 'POST') {
|
|
let payload;
|
|
try { payload = JSON.parse(await new Promise((resolve, reject) => {
|
|
let body = '';
|
|
request.on('data', (chunk) => { body += chunk; if (body.length > 100_000) reject(new Error('too large')); });
|
|
request.on('end', () => resolve(body));
|
|
request.on('error', reject);
|
|
})); } catch {
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 400, reason: 'invalid-json' }));
|
|
return json(response, 400, { detail: 'Invalid request body.' });
|
|
}
|
|
|
|
try {
|
|
const decoded = await auth.verifyIdToken(payload?.id_token);
|
|
if (decoded.aud !== projectId || decoded.iss !== `https://securetoken.google.com/${projectId}`) {
|
|
throw new Error('Project claims mismatch.');
|
|
}
|
|
const accessToken = `qa-session-${randomBytes(24).toString('hex')}`;
|
|
const refreshToken = `qa-refresh-${randomBytes(24).toString('hex')}`;
|
|
sessions.set(accessToken, decoded.uid);
|
|
sessions.set(refreshToken, decoded.uid);
|
|
if (!deviceSessions.has(decoded.uid)) {
|
|
const now = Date.now();
|
|
deviceSessions.set(decoded.uid, Array.from({ length: sessionCount }, (_, i) => ({
|
|
id: `qa-device-${String(i).padStart(2, '0')}`,
|
|
device_name: `Isolated Device ${String(i).padStart(2, '0')}`,
|
|
current: i === 0,
|
|
created_at: new Date(now - i * 60_000).toISOString(),
|
|
})));
|
|
}
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 200, verified: true, projectId, uidPrefix: decoded.uid.slice(0, 8) }));
|
|
return json(response, 200, { access_token: accessToken, refresh_token: refreshToken });
|
|
} catch {
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 401, verified: false, projectId }));
|
|
return json(response, 401, { detail: 'Firebase token verification failed.' });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === '/api/v1/auth/login' && request.method === 'POST') {
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 401, reason: 'email-auth-not-configured-in-local-fixture' }));
|
|
return json(response, 401, { detail: 'Email authentication is not configured in this local QA fixture.' });
|
|
}
|
|
|
|
if (url.pathname === '/api/v1/auth/sessions' && request.method === 'GET') {
|
|
const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
const ok = Boolean(token && sessions.has(token));
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 200 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
|
|
return ok ? json(response, 200, deviceSessions.get(sessions.get(token)))
|
|
: json(response, 401, { detail: 'Authentication required.' });
|
|
}
|
|
|
|
if ((url.pathname.startsWith('/api/v1/auth/sessions/') && request.method === 'DELETE') ||
|
|
(url.pathname === '/api/v1/auth/logout-all' && request.method === 'POST')) {
|
|
const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
const uid = sessions.get(token);
|
|
if (!uid) return json(response, 401, { detail: 'Authentication required.' });
|
|
const id = url.pathname.split('/').at(-1);
|
|
const rows = deviceSessions.get(uid) ?? [];
|
|
deviceSessions.set(uid, request.method === 'DELETE' ? rows.filter((row) => row.id !== id) : []);
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 204, remaining: deviceSessions.get(uid).length }));
|
|
response.writeHead(204);
|
|
return response.end();
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/v1/auth/') && ['POST', 'DELETE'].includes(request.method ?? '')) {
|
|
const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
const ok = Boolean(token && sessions.has(token));
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 204 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
|
|
response.writeHead(ok ? 204 : 401);
|
|
return response.end();
|
|
}
|
|
|
|
console.log(JSON.stringify({ endpoint: url.pathname, status: 404 }));
|
|
return json(response, 404, { detail: 'Local QA endpoint not found.' });
|
|
});
|
|
|
|
server.listen(port, '127.0.0.1', () => console.log(JSON.stringify({ listening: `127.0.0.1:${port}`, projectId, authEmulatorHost })));
|