Spaces:
Running
Running
| const express = require('express'); | |
| const admin = require('firebase-admin'); | |
| const jwt = require('jsonwebtoken'); | |
| const { v4: uuidv4 } = require('uuid'); | |
| const axios = require('axios'); | |
| const bodyParser = require('body-parser'); | |
| const app = express(); | |
| app.use(bodyParser.json()); | |
| // --------------------------------------------------------- | |
| // 1. STATE MANAGEMENT (In-Memory DB) | |
| // --------------------------------------------------------- | |
| // Store temporary "proj_" redemption keys. | |
| // Structure: { "proj_xyz": { uid: "...", projectId: "...", expiresAt: timestamp } } | |
| const tempKeys = new Map(); | |
| // Store JWT Secrets (Hydrated Cache). | |
| // Structure: { "uid:projectId": { secret: "...", lastAccessed: timestamp } } | |
| const activeSessions = new Map(); | |
| let db = null; // Firebase DB Reference | |
| // --------------------------------------------------------- | |
| // 2. FIREBASE INITIALIZATION | |
| // --------------------------------------------------------- | |
| try { | |
| if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) { | |
| const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON); | |
| if (admin.apps.length === 0) { | |
| admin.initializeApp({ | |
| credential: admin.credential.cert(serviceAccount), | |
| databaseURL: process.env.FIREBASE_DB_URL || "https://your-firebase-project.firebaseio.com" | |
| }); | |
| } | |
| db = admin.database(); | |
| console.log("🔥 Firebase Connected & StateManager Linked"); | |
| } else { | |
| console.warn("⚠️ Memory-Only mode (No Firebase JSON provided)."); | |
| } | |
| } catch (e) { | |
| console.error("Firebase Init Error:", e); | |
| } | |
| // --------------------------------------------------------- | |
| // 3. HELPER FUNCTIONS & MIDDLEWARE | |
| // --------------------------------------------------------- | |
| // Middleware to verify Firebase ID Token (for /key and /nullify) | |
| // Includes a Debug Bypass variable | |
| const verifyFirebaseUser = async (req, res, next) => { | |
| const debugMode = process.env.DEBUG_NO_AUTH === 'true'; // Set to true in .env for testing without valid tokens | |
| if (debugMode) { | |
| req.user = { uid: "debug_user_001" }; | |
| return next(); | |
| } | |
| const authHeader = req.headers.authorization; | |
| if (!authHeader || !authHeader.startsWith('Bearer ')) { | |
| return res.status(401).json({ error: 'Missing Bearer token' }); | |
| } | |
| const idToken = authHeader.split('Bearer ')[1]; | |
| try { | |
| if (admin.apps.length > 0) { | |
| const decodedToken = await admin.auth().verifyIdToken(idToken); | |
| req.user = decodedToken; | |
| next(); | |
| } else { | |
| // Fallback for memory-only mode without validation | |
| req.user = { uid: "memory_user" }; | |
| next(); | |
| } | |
| } catch (error) { | |
| return res.status(403).json({ error: 'Unauthorized', details: error.message }); | |
| } | |
| }; | |
| // Helper to fetch Secret (Memory -> DB -> 404) | |
| async function getSessionSecret(uid, projectId) { | |
| const cacheKey = `${uid}:${projectId}`; | |
| // 1. Check Memory | |
| if (activeSessions.has(cacheKey)) { | |
| const session = activeSessions.get(cacheKey); | |
| session.lastAccessed = Date.now(); // Update access time for cleanup | |
| return session.secret; | |
| } | |
| // 2. Hydrate from DB | |
| if (db) { | |
| try { | |
| const snapshot = await db.ref(`plugin_oauth/${uid}/${projectId}`).once('value'); | |
| if (snapshot.exists()) { | |
| const secret = snapshot.val(); | |
| // Store in memory for next time | |
| activeSessions.set(cacheKey, { secret, lastAccessed: Date.now() }); | |
| console.log(`💧 Hydrated secret for ${cacheKey} from DB`); | |
| return secret; | |
| } | |
| } catch (err) { | |
| console.error("DB Read Error:", err); | |
| } | |
| } | |
| // 3. Not found | |
| return null; | |
| } | |
| // --------------------------------------------------------- | |
| // 4. ENDPOINTS | |
| // --------------------------------------------------------- | |
| // Endpoint: /key | |
| // Generates a temporary 'proj_' token. Expects Firebase Auth. | |
| app.post('/key', verifyFirebaseUser, (req, res) => { | |
| const { projectId } = req.body; | |
| if (!projectId) return res.status(400).json({ error: 'projectId required' }); | |
| const key = `proj_${uuidv4().replace(/-/g, '')}`; | |
| // Store in memory (valid for 5 minutes) | |
| tempKeys.set(key, { | |
| uid: req.user.uid, | |
| projectId: projectId, | |
| createdAt: Date.now() | |
| }); | |
| console.log(`🔑 Generated Key for user ${req.user.uid}: ${key}`); | |
| res.json({ key, expiresIn: 300 }); | |
| }); | |
| // Endpoint: /redeem | |
| // Exchanges 'proj_' key for a JWT. | |
| app.post('/redeem', async (req, res) => { | |
| const { key } = req.body; | |
| if (!key || !tempKeys.has(key)) { | |
| return res.status(404).json({ error: 'Invalid or expired key' }); | |
| } | |
| const data = tempKeys.get(key); | |
| // Generate a unique secret for this session/project | |
| const sessionSecret = uuidv4(); | |
| // Create JWT | |
| const token = jwt.sign( | |
| { uid: data.uid, projectId: data.projectId }, | |
| sessionSecret, | |
| { expiresIn: '7d' } // Plugin token validity | |
| ); | |
| const cacheKey = `${data.uid}:${data.projectId}`; | |
| // 1. Store in Memory | |
| activeSessions.set(cacheKey, { secret: sessionSecret, lastAccessed: Date.now() }); | |
| // 2. Store in Firebase (Persist) | |
| if (db) { | |
| await db.ref(`plugin_oauth/${data.uid}/${data.projectId}`).set(sessionSecret); | |
| } | |
| // Burn the redemption key (One-time use) | |
| tempKeys.delete(key); | |
| console.log(`🚀 Redeemed JWT for ${cacheKey}`); | |
| res.json({ token }); | |
| }); | |
| // Endpoint: /poll | |
| // Verifies JWT using the stored secret and forwards to external server. | |
| app.post('/poll', async (req, res) => { | |
| const { token, payload: clientPayload } = req.body; | |
| if (!token) return res.status(400).json({ error: 'Token required' }); | |
| // Decode without verification first to find WHO it is | |
| const decoded = jwt.decode(token); | |
| if (!decoded || !decoded.uid || !decoded.projectId) { | |
| return res.status(401).json({ error: 'Malformed token' }); | |
| } | |
| // Fetch secret (Memory -> DB -> 404) | |
| const secret = await getSessionSecret(decoded.uid, decoded.projectId); | |
| if (!secret) { | |
| return res.status(404).json({ error: 'Session revoked or not found' }); | |
| } | |
| // Verify signature | |
| try { | |
| jwt.verify(token, secret); | |
| // If valid, post to external server | |
| const externalUrl = process.env.EXTERNAL_SERVER_URL || 'https://httpbin.org/post'; // Default for testing | |
| try { | |
| const response = await axios.post(externalUrl, { | |
| user: decoded.uid, | |
| project: decoded.projectId, | |
| data: clientPayload | |
| }); | |
| return res.json({ status: 'success', externalResponse: response.data }); | |
| } catch (extError) { | |
| return res.status(502).json({ error: 'External server error' }); | |
| } | |
| } catch (err) { | |
| return res.status(403).json({ error: 'Invalid Token Signature' }); | |
| } | |
| }); | |
| // Endpoint: /cleanup | |
| // Memory management: Clears old JWT secrets from RAM that haven't been used recently. | |
| // Does NOT delete from Firebase. | |
| app.post('/cleanup', (req, res) => { | |
| const THRESHOLD = 1000 * 60 * 60; // 1 Hour | |
| const now = Date.now(); | |
| let cleanedCount = 0; | |
| for (const [key, value] of activeSessions.entries()) { | |
| if (now - value.lastAccessed > THRESHOLD) { | |
| activeSessions.delete(key); | |
| cleanedCount++; | |
| } | |
| } | |
| // Also clean old temp keys (older than 10 mins) | |
| for (const [key, value] of tempKeys.entries()) { | |
| if (now - value.createdAt > (1000 * 60 * 10)) { | |
| tempKeys.delete(key); | |
| } | |
| } | |
| console.log(`🧹 Garbage Collection: Removed ${cleanedCount} cached sessions.`); | |
| res.json({ message: `Cleaned ${cleanedCount} cached sessions from memory.` }); | |
| }); | |
| // Endpoint: /nullify | |
| // Security Nuke: Removes session from Memory AND Firebase. | |
| app.post('/nullify', verifyFirebaseUser, async (req, res) => { | |
| const { projectId } = req.body; | |
| if (!projectId) return res.status(400).json({ error: 'projectId required' }); | |
| const cacheKey = `${req.user.uid}:${projectId}`; | |
| // 1. Remove from Memory | |
| const existedInMemory = activeSessions.delete(cacheKey); | |
| // 2. Remove from Firebase | |
| if (db) { | |
| try { | |
| await db.ref(`plugin_oauth/${req.user.uid}/${projectId}`).remove(); | |
| } catch (e) { | |
| return res.status(500).json({ error: 'Database error during nullify' }); | |
| } | |
| } | |
| console.log(`☢️ NULLIFIED session for ${cacheKey}`); | |
| res.json({ | |
| success: true, | |
| message: 'Session secrets purged from memory and database.', | |
| wasCached: existedInMemory | |
| }); | |
| }); | |
| // --------------------------------------------------------- | |
| // 5. EMBEDDED HTML DEBUGGER | |
| // --------------------------------------------------------- | |
| app.get('/', (req, res) => { | |
| res.send(` | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Roblox Plugin Auth Server</title> | |
| <style> | |
| body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; max-width: 800px; margin: 0 auto; } | |
| .container { background: #252526; padding: 20px; border-radius: 8px; border: 1px solid #333; } | |
| input, button { padding: 10px; margin: 5px 0; width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #555; color: white; } | |
| button { background: #0e639c; cursor: pointer; font-weight: bold; } | |
| button:hover { background: #1177bb; } | |
| button.danger { background: #a51d2d; } | |
| label { display: block; margin-top: 10px; color: #9cdcfe; } | |
| pre { background: #000; padding: 10px; border: 1px solid #444; overflow-x: auto; white-space: pre-wrap; } | |
| .status { margin-top: 20px; padding: 10px; border-left: 4px solid #0e639c; background: #2d2d2d; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>🔌 Plugin Auth Debugger</h1> | |
| <div class="container"> | |
| <h3>1. Configuration</h3> | |
| <label>Firebase ID Token (leave empty if DEBUG_NO_AUTH=true)</label> | |
| <input type="text" id="fbToken" placeholder="eyJhbG..."> | |
| <label>Project ID</label> | |
| <input type="text" id="projId" value="roblox_world_1"> | |
| <hr style="border-color:#444"> | |
| <h3>2. Generate Key (/key)</h3> | |
| <button onclick="generateKey()">Generate PROJ_ Key</button> | |
| <pre id="keyResult">Waiting...</pre> | |
| <h3>3. Redeem Token (/redeem)</h3> | |
| <label>Key to Redeem</label> | |
| <input type="text" id="redeemKeyInput"> | |
| <button onclick="redeemKey()">Redeem for JWT</button> | |
| <pre id="jwtResult">Waiting...</pre> | |
| <h3>4. Poll External (/poll)</h3> | |
| <label>JWT Token</label> | |
| <input type="text" id="jwtInput"> | |
| <button onclick="poll()">Poll External Server</button> | |
| <pre id="pollResult">Waiting...</pre> | |
| <h3>5. Management</h3> | |
| <div style="display:flex; gap:10px;"> | |
| <button onclick="cleanup()">Memory Cleanup</button> | |
| <button class="danger" onclick="nullify()">Nullify (Nuke)</button> | |
| </div> | |
| <pre id="mgmtResult">Waiting...</pre> | |
| </div> | |
| <script> | |
| const baseUrl = ''; | |
| async function generateKey() { | |
| const token = document.getElementById('fbToken').value; | |
| const projectId = document.getElementById('projId').value; | |
| const headers = {}; | |
| if(token) headers['Authorization'] = 'Bearer ' + token; | |
| const res = await fetch(baseUrl + '/key', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...headers }, | |
| body: JSON.stringify({ projectId }) | |
| }); | |
| const data = await res.json(); | |
| document.getElementById('keyResult').innerText = JSON.stringify(data, null, 2); | |
| if(data.key) document.getElementById('redeemKeyInput').value = data.key; | |
| } | |
| async function redeemKey() { | |
| const key = document.getElementById('redeemKeyInput').value; | |
| const res = await fetch(baseUrl + '/redeem', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ key }) | |
| }); | |
| const data = await res.json(); | |
| document.getElementById('jwtResult').innerText = JSON.stringify(data, null, 2); | |
| if(data.token) document.getElementById('jwtInput').value = data.token; | |
| } | |
| async function poll() { | |
| const token = document.getElementById('jwtInput').value; | |
| const res = await fetch(baseUrl + '/poll', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ token, payload: { msg: "Hello from Roblox" } }) | |
| }); | |
| const data = await res.json(); | |
| document.getElementById('pollResult').innerText = JSON.stringify(data, null, 2); | |
| } | |
| async function cleanup() { | |
| const res = await fetch(baseUrl + '/cleanup', { method: 'POST' }); | |
| const data = await res.json(); | |
| document.getElementById('mgmtResult').innerText = JSON.stringify(data, null, 2); | |
| } | |
| async function nullify() { | |
| const token = document.getElementById('fbToken').value; | |
| const projectId = document.getElementById('projId').value; | |
| const headers = {}; | |
| if(token) headers['Authorization'] = 'Bearer ' + token; | |
| const res = await fetch(baseUrl + '/nullify', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...headers }, | |
| body: JSON.stringify({ projectId }) | |
| }); | |
| const data = await res.json(); | |
| document.getElementById('mgmtResult').innerText = JSON.stringify(data, null, 2); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| `); | |
| }); | |
| const PORT = process.env.PORT || 7860; | |
| app.listen(PORT, () => { | |
| console.log(`🚀 Server running on http://localhost:${PORT}`); | |
| }); |