diff --git a/.env.example b/.env.example index 609d316..7644198 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,10 @@ VITE_SIGNALING_SERVER_URL=wss://signaling.gdsjam.com VITE_SIGNALING_SERVER_TOKEN=your-token-here # TURN Server Configuration (for WebRTC NAT traversal) -# Password for TURN server authentication (required for file sync) +# Optional fallback only. Preferred path is server-issued ephemeral TURN credentials. VITE_TURN_PASSWORD=your-turn-password-here # File Server Configuration VITE_FILE_SERVER_URL=https://signaling.gdsjam.com -VITE_FILE_SERVER_TOKEN=your-token-here +# Browser clients now request short-lived scoped tokens from /api/auth/token. +# Do not expose long-lived file-server auth tokens in Vite env variables. diff --git a/.env.production b/.env.production index 0b89c50..0b37220 100644 --- a/.env.production +++ b/.env.production @@ -16,10 +16,10 @@ VITE_FPS_UPDATE_INTERVAL=500 # VITE_SIGNALING_SERVER_TOKEN= # TURN Server Configuration (for WebRTC NAT traversal) +# Optional fallback only. Preferred path is server-issued ephemeral TURN credentials. # VITE_TURN_PASSWORD= # File Server Configuration (for file upload/download) # These should be set in GitHub Secrets for production deployment # VITE_FILE_SERVER_URL=https://signaling.gdsjam.com -# VITE_FILE_SERVER_TOKEN= - +# Browser clients now use short-lived scoped tokens from /api/auth/token. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4f31cf3..ff96a3e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,7 +46,6 @@ jobs: VITE_TURN_PASSWORD: ${{ secrets.VITE_TURN_PASSWORD }} # File server configuration (required for file upload/download) VITE_FILE_SERVER_URL: ${{ secrets.VITE_FILE_SERVER_URL }} - VITE_FILE_SERVER_TOKEN: ${{ secrets.VITE_FILE_SERVER_TOKEN }} # Debug mode (optional - defaults to .env.production value if not set) # To enable debug in production, add VITE_DEBUG secret with value "true" VITE_DEBUG: ${{ secrets.VITE_DEBUG }} @@ -69,4 +68,3 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 - diff --git a/DevLog/DevLog-006-00-Viewer-Stability-and-Refactor-Fixes.md b/DevLog/DevLog-006-00-Viewer-Stability-and-Refactor-Fixes.md new file mode 100644 index 0000000..56a5829 --- /dev/null +++ b/DevLog/DevLog-006-00-Viewer-Stability-and-Refactor-Fixes.md @@ -0,0 +1,124 @@ +# DevLog-006-00: Viewer Stability, API Token, and TURN Hardening + +**Date**: 2026-02-15 +**Status**: Complete +**Scope**: Consolidated execution log for DevLog-006 series: +- 006-00 Viewer stability and refactor fixes +- 006-01 Short-lived API token hardening +- 006-02 Ephemeral TURN credentials hardening + +## Goals + +1. Fix event-listener lifecycle leaks in viewer/renderer code paths. +2. Restore `pnpm check` pass status by removing known check blockers. +3. Replace long-lived client-exposed API token usage with short-lived scoped tokens. +4. Replace static TURN password runtime dependency with ephemeral TURN credentials. +5. Keep all changes behavior-preserving, migration-safe, and validated. + +## Plan + +- [x] Patch `PixiRenderer` layer-visibility listener to use a stable handler reference. +- [x] Patch `ViewerCanvas` custom resize listener to clean up correctly on unmount. +- [x] Patch `MeasurementOverlay` constructor/state to remove unused `app` member. +- [x] Add shared server auth module for short-lived scoped API tokens. +- [x] Add token issuance endpoint `POST /api/auth/token`. +- [x] Migrate file/python client calls to ephemeral token flow. +- [x] Add ephemeral TURN credential endpoint `GET /api/turn-credentials`. +- [x] Integrate TURN credential fetch in `YjsProvider` with static fallback. +- [x] Update docs/env/workflow notes for new security model. +- [x] Validate all slices with `pnpm check`, tests, and server syntax checks. + +## Progress Log + +### 2026-02-15 13:10 + +- Created DevLog and locked execution scope to targeted stability fixes. +- Next: apply code patches for listener cleanup and check failure. + +### 2026-02-15 13:18 + +- Completed viewer stability code patches: + - `src/lib/renderer/PixiRenderer.ts`: stable handler reference for add/remove listener symmetry. + - `src/components/viewer/ViewerCanvas.svelte`: removed inline custom resize listener and added cleanup. + - `src/lib/renderer/overlays/MeasurementOverlay.ts`: removed unused `app` member and simplified constructor. +- Next: run validation (`pnpm check`, `pnpm test --run`) and log results. + +### 2026-02-15 13:24 + +- Stability validation results: + - `pnpm check`: pass (`svelte-check found 0 errors and 0 warnings`). + - `pnpm test --run`: pass (6 files, 94 tests). +- Next: security hardening follow-up. + +### 2026-02-15 13:34 + +- Started API token hardening with shared scope design: + - `files:read` + - `files:write` + - `python:execute` +- Next: patch server auth and token issuing endpoint. + +### 2026-02-15 13:52 + +- Implemented API token hardening: + - Added `server/auth.js` for short-lived token signing/verification and scoped middleware. + - Added `POST /api/auth/token` in `server/server.js` with per-IP rate limit. + - Switched `server/fileStorage.js` and `server/pythonExecutor.js` to scoped auth middleware. + - Added `src/lib/api/authTokenClient.ts` with in-memory token cache. + - Replaced direct `VITE_FILE_SERVER_TOKEN` usage in `FileTransfer`, `SessionManager`, and `PythonExecutor`. + - Updated env/workflow/docs to remove browser long-lived file token usage. +- Next: run full validation and finalize status. + +### 2026-02-15 14:16 + +- API token hardening validation: + - `pnpm check`: pass (0 errors, 0 warnings) + - `pnpm test --run`: pass (6 files, 94 tests) + - `node --check` on modified server files: pass +- Patch complete. Long-lived browser-exposed file token usage removed from runtime code paths. + +### 2026-02-15 14:24 + +- Started TURN hardening after two pushed commits: + - `ed7340b` stability fixes + - `01af1b7` short-lived API token hardening +- Next: implement server endpoint and client integration. + +### 2026-02-15 14:41 + +- Added TURN hardening implementation: + - `server/turnCredentials.js`: `GET /api/turn-credentials` endpoint for ephemeral TURN credentials. + - `server/auth.js`: added `turn:read` scope. + - `src/lib/api/turnCredentialsClient.ts`: client fetch + in-memory cache. + - `src/lib/collaboration/YjsProvider.ts`: fetches ephemeral TURN creds first, falls back to static `VITE_TURN_PASSWORD`. + - `src/lib/collaboration/SessionManager.ts`: awaits async `yjsProvider.connect(...)`. +- Updated environment/docs for TURN REST secret setup. +- Next: run validation and finalize status. + +### 2026-02-15 14:46 + +- TURN hardening validation: + - `pnpm check`: pass (0 errors, 0 warnings) + - `pnpm test --run`: pass (6 files, 94 tests) + - `node --check` for modified server files: pass +- TURN hardening slice complete. + +### 2026-02-15 15:28 + +- Added automated test coverage for the new security and TURN logic: + - `tests/api/authTokenClient.test.ts` + - `tests/api/turnCredentialsClient.test.ts` + - `tests/api/pythonExecutor.auth.test.ts` + - `tests/collaboration/FileTransfer.auth.test.ts` + - `tests/collaboration/YjsProvider.turn.test.ts` + - `tests/server/auth.test.ts` + - `tests/server/turnCredentials.test.ts` +- Test run result: + - `pnpm test --run`: pass (13 files, 109 tests) + +## Sensitive Data Review + +- Reviewed consolidated DevLog content for secrets/tokens/credentials. +- No raw secret values, real tokens, passwords, private keys, or credential strings are present. +- Mentions of environment variables (`AUTH_TOKEN`, `API_TOKEN_SECRET`, `TURN_SHARED_SECRET`, `VITE_TURN_PASSWORD`) are generic configuration identifiers only. +- Commit hashes included are public git identifiers, not sensitive data. diff --git a/server/.env.example b/server/.env.example index fcbf3d9..931e9ea 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,20 @@ PORT=4444 # This token must be included in the WebSocket URL: ws://server:4444?token=YOUR_TOKEN AUTH_TOKEN=your-secure-token-here +# Short-lived API token signing secret (optional but recommended). +# If not set, AUTH_TOKEN is used as fallback secret. +API_TOKEN_SECRET=your-api-token-signing-secret +# API token TTL in seconds (default: 300) +# API_TOKEN_TTL_SECONDS=300 + +# TURN REST credential configuration (for ephemeral TURN credentials) +# coturn should be configured with `use-auth-secret` and same static-auth-secret value. +TURN_SHARED_SECRET=your-turn-shared-secret +# TURN_REALM=signaling.gdsjam.com +# TURN_TTL_SECONDS=600 +# TURN_USERNAME_PREFIX=gdsjam +# TURN_URLS=turn:signaling.gdsjam.com:3478,turn:signaling.gdsjam.com:3478?transport=tcp,turns:signaling.gdsjam.com:5349?transport=tcp + # Allowed Origins (comma-separated) # Only connections from these origins will be accepted # Default: https://gdsjam.com,http://localhost:5173,http://localhost:4173 diff --git a/server/README.md b/server/README.md index 6636bd9..7e3b948 100644 --- a/server/README.md +++ b/server/README.md @@ -36,7 +36,7 @@ PORT=8080 pnpm start The server implements three layers of security: -### 1. Token Authentication +### 1. Token Authentication (WebSocket + API) Set an `AUTH_TOKEN` in your `.env` file: @@ -53,7 +53,19 @@ Clients must include the token in the WebSocket URL: ws://your-server:4444?token=your-generated-token-here ``` -**Note**: Since GDSJam is a client-side app, the token will be visible in the client code. This provides basic protection against casual abuse but is not fully secure. Anyone inspecting the client code can extract the token. +For REST APIs (`/api/files`, `/api/execute`), browser clients should request short-lived scoped tokens: + +```bash +POST /api/auth/token +{ "scopes": ["files:read", "files:write"] } +``` + +These tokens are: +- scoped (`files:read`, `files:write`, `python:execute`, `turn:read`) +- short-lived (default 5 minutes) +- bound to client IP + +Long-lived `AUTH_TOKEN` is still accepted for operational backward compatibility. ### 2. Origin Checking @@ -72,6 +84,7 @@ ALLOWED_ORIGINS=https://gdsjam.com,https://yourdomain.com Protects against DoS attacks: - **Default**: 10 connections per IP per minute - Configurable in `server.js` (RATE_LIMIT_WINDOW, RATE_LIMIT_MAX_CONNECTIONS) +- API token issuance is also rate-limited per IP (`API_TOKEN_RATE_LIMIT_*`) ### Security Limitations @@ -182,6 +195,12 @@ Key settings: - Domain: signaling.gdsjam.com - SSL certificate: /etc/letsencrypt/live/signaling.gdsjam.com/ +For ephemeral TURN credentials (recommended), configure coturn with TURN REST auth: +- `use-auth-secret` +- `static-auth-secret=` + +The server then exposes `GET /api/turn-credentials` and clients no longer need a static TURN password in frontend env. + To modify configuration: ```bash sudo nano /etc/turnserver.conf diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..e49b742 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,154 @@ +const crypto = require("crypto"); + +const AUTH_TOKEN = process.env.AUTH_TOKEN; +const API_TOKEN_SECRET = process.env.API_TOKEN_SECRET || AUTH_TOKEN || ""; +const API_TOKEN_TTL_SECONDS = parseInt(process.env.API_TOKEN_TTL_SECONDS || "300", 10); // 5 min + +const ALLOWED_SCOPES = new Set(["files:read", "files:write", "python:execute", "turn:read"]); + +function toBase64Url(input) { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function fromBase64Url(input) { + const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); + const pad = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)); + return Buffer.from(normalized + pad, "base64").toString("utf8"); +} + +function normalizeIp(req) { + // Respect common proxy headers when present. + const forwarded = req.headers["x-forwarded-for"]; + const candidate = Array.isArray(forwarded) + ? forwarded[0] + : typeof forwarded === "string" + ? forwarded.split(",")[0] + : req.ip || req.socket?.remoteAddress || ""; + return String(candidate).trim(); +} + +function parseBearerToken(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) return null; + return authHeader.substring(7); +} + +function getValidatedScopes(scopes) { + if (!Array.isArray(scopes) || scopes.length === 0) return []; + const unique = [...new Set(scopes.map((s) => String(s)))]; + for (const scope of unique) { + if (!ALLOWED_SCOPES.has(scope)) { + throw new Error(`Invalid scope: ${scope}`); + } + } + return unique; +} + +function signApiToken(payload) { + if (!API_TOKEN_SECRET) { + throw new Error("API_TOKEN_SECRET (or AUTH_TOKEN) is required to sign API tokens"); + } + const body = toBase64Url(JSON.stringify(payload)); + const signature = crypto.createHmac("sha256", API_TOKEN_SECRET).update(body).digest("base64url"); + return `${body}.${signature}`; +} + +function verifyApiToken(token) { + if (!API_TOKEN_SECRET) return { valid: false, reason: "Server auth secret not configured" }; + const parts = token.split("."); + if (parts.length !== 2) return { valid: false, reason: "Invalid token format" }; + + const [body, signature] = parts; + const expectedSignature = crypto + .createHmac("sha256", API_TOKEN_SECRET) + .update(body) + .digest("base64url"); + if (signature !== expectedSignature) return { valid: false, reason: "Invalid token signature" }; + + let payload; + try { + payload = JSON.parse(fromBase64Url(body)); + } catch { + return { valid: false, reason: "Invalid token payload" }; + } + + const now = Date.now(); + if (typeof payload.exp !== "number" || payload.exp <= now) { + return { valid: false, reason: "Token expired" }; + } + if (!Array.isArray(payload.scopes)) { + return { valid: false, reason: "Invalid token scopes" }; + } + return { valid: true, payload }; +} + +function hasRequiredScopes(tokenScopes, requiredScopes) { + if (!requiredScopes || requiredScopes.length === 0) return true; + if (!Array.isArray(tokenScopes)) return false; + return requiredScopes.every((scope) => tokenScopes.includes(scope)); +} + +function authenticateRequest(requiredScopes = []) { + return (req, res, next) => { + if (!AUTH_TOKEN) { + return next(); // auth disabled + } + + const token = parseBearerToken(req); + if (!token) { + return res.status(401).json({ error: "Missing or invalid authorization header" }); + } + + // Backward compatibility: allow long-lived AUTH_TOKEN for operators/tools. + if (token === AUTH_TOKEN) { + return next(); + } + + const verification = verifyApiToken(token); + if (!verification.valid) { + return res.status(401).json({ error: verification.reason || "Invalid token" }); + } + + const requestIp = normalizeIp(req); + if (verification.payload.ip && verification.payload.ip !== requestIp) { + return res.status(401).json({ error: "Token IP mismatch" }); + } + if (!hasRequiredScopes(verification.payload.scopes, requiredScopes)) { + return res.status(403).json({ error: "Insufficient token scope" }); + } + + req.apiToken = verification.payload; + next(); + }; +} + +function issueShortLivedToken(req, scopes = []) { + const validatedScopes = getValidatedScopes(scopes); + const now = Date.now(); + const expiresAt = now + API_TOKEN_TTL_SECONDS * 1000; + const payload = { + iat: now, + exp: expiresAt, + ip: normalizeIp(req), + scopes: validatedScopes, + }; + return { + token: signApiToken(payload), + expiresAt, + expiresIn: API_TOKEN_TTL_SECONDS, + scopes: validatedScopes, + }; +} + +module.exports = { + ALLOWED_SCOPES, + AUTH_TOKEN, + API_TOKEN_TTL_SECONDS, + authenticateRequest, + getValidatedScopes, + issueShortLivedToken, +}; diff --git a/server/fileStorage.js b/server/fileStorage.js index ba4d768..d30da84 100644 --- a/server/fileStorage.js +++ b/server/fileStorage.js @@ -2,11 +2,11 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const multer = require("multer"); +const { authenticateRequest } = require("./auth"); const FILE_STORAGE_PATH = process.env.FILE_STORAGE_PATH || "/var/gdsjam/files"; const MAX_FILE_SIZE_MB = parseInt(process.env.MAX_FILE_SIZE_MB || "100", 10); const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; -const AUTH_TOKEN = process.env.AUTH_TOKEN; // Rate limiting for file uploads (count-based) const uploadTracker = new Map(); // Map @@ -66,27 +66,6 @@ function validateFileId(fileId) { return /^[a-f0-9]{64}$/.test(fileId); } -/** - * Authenticate request using Bearer token - */ -function authenticateRequest(req, res, next) { - if (!AUTH_TOKEN) { - return next(); // No auth required if token not set - } - - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return res.status(401).json({ error: "Missing or invalid authorization header" }); - } - - const token = authHeader.substring(7); - if (token !== AUTH_TOKEN) { - return res.status(401).json({ error: "Invalid token" }); - } - - next(); -} - /** * Rate limit file uploads (count-based, per hour) */ @@ -170,7 +149,7 @@ function setupFileRoutes(app) { */ app.post( "/api/files", - authenticateRequest, + authenticateRequest(["files:write"]), rateLimitUploads, upload.single("file"), async (req, res) => { @@ -252,7 +231,7 @@ function setupFileRoutes(app) { * GET /api/files/:fileId * Download a file by its fileId (SHA-256 hash) */ - app.get("/api/files/:fileId", authenticateRequest, (req, res) => { + app.get("/api/files/:fileId", authenticateRequest(["files:read"]), (req, res) => { try { const { fileId } = req.params; @@ -303,7 +282,7 @@ function setupFileRoutes(app) { * DELETE /api/files/:fileId * Delete a file by its fileId (optional, for manual cleanup) */ - app.delete("/api/files/:fileId", authenticateRequest, (req, res) => { + app.delete("/api/files/:fileId", authenticateRequest(["files:write"]), (req, res) => { try { const { fileId } = req.params; diff --git a/server/pythonExecutor.js b/server/pythonExecutor.js index e180b86..2798c8e 100644 --- a/server/pythonExecutor.js +++ b/server/pythonExecutor.js @@ -3,6 +3,7 @@ const path = require("node:path"); const { spawn } = require("node:child_process"); const os = require("node:os"); const crypto = require("node:crypto"); +const { authenticateRequest } = require("./auth"); // Configuration from environment const PYTHON_VENV_PATH = process.env.PYTHON_VENV_PATH || "/opt/gdsjam/venv"; @@ -12,7 +13,6 @@ const PYTHON_RATE_LIMIT_MAX = parseInt(process.env.PYTHON_RATE_LIMIT_MAX || "10" const FILE_STORAGE_PATH = process.env.FILE_STORAGE_PATH || "/var/gdsjam/files"; const MAX_GDS_SIZE_MB = parseInt(process.env.MAX_GDS_SIZE_MB || "100", 10); const MAX_GDS_SIZE_BYTES = MAX_GDS_SIZE_MB * 1024 * 1024; -const AUTH_TOKEN = process.env.AUTH_TOKEN; // Rate limiting tracker const executionTracker = new Map(); // Map @@ -564,33 +564,6 @@ function rateLimitExecution(req, res, next) { next(); } -/** - * Authentication middleware (reuses AUTH_TOKEN from fileStorage) - */ -function authenticateRequest(req, res, next) { - if (!AUTH_TOKEN) { - return next(); // No auth required if token not set - } - - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return res.status(401).json({ - success: false, - error: "Missing or invalid authorization header", - }); - } - - const token = authHeader.substring(7); - if (token !== AUTH_TOKEN) { - return res.status(401).json({ - success: false, - error: "Invalid token", - }); - } - - next(); -} - /** * Setup Python execution routes */ @@ -602,48 +575,53 @@ function setupPythonRoutes(app) { * POST /api/execute * Execute Python/gdsfactory code and return generated GDS file */ - app.post("/api/execute", authenticateRequest, rateLimitExecution, async (req, res) => { - try { - const { code } = req.body; + app.post( + "/api/execute", + authenticateRequest(["python:execute"]), + rateLimitExecution, + async (req, res) => { + try { + const { code } = req.body; - if (!code || typeof code !== "string") { - return res.status(400).json({ - success: false, - error: "Missing or invalid 'code' field in request body", - }); - } + if (!code || typeof code !== "string") { + return res.status(400).json({ + success: false, + error: "Missing or invalid 'code' field in request body", + }); + } - // Limit code size (prevent DoS with huge payloads) - const MAX_CODE_SIZE = 100 * 1024; // 100KB - if (code.length > MAX_CODE_SIZE) { - return res.status(400).json({ - success: false, - error: `Code too large. Maximum size is ${MAX_CODE_SIZE / 1024}KB`, - }); - } + // Limit code size (prevent DoS with huge payloads) + const MAX_CODE_SIZE = 100 * 1024; // 100KB + if (code.length > MAX_CODE_SIZE) { + return res.status(400).json({ + success: false, + error: `Code too large. Maximum size is ${MAX_CODE_SIZE / 1024}KB`, + }); + } - const clientIp = req.ip || req.socket.remoteAddress; - console.log( - `[${new Date().toISOString()}] Python execution request from ${clientIp} (${code.length} bytes)`, - ); + const clientIp = req.ip || req.socket.remoteAddress; + console.log( + `[${new Date().toISOString()}] Python execution request from ${clientIp} (${code.length} bytes)`, + ); - const result = await executePythonCode(code, clientIp); + const result = await executePythonCode(code, clientIp); - if (result.success) { - res.json(result); - } else { - // Use 200 for execution errors (client can handle based on success field) - // Use 4xx/5xx for request-level errors - res.json(result); + if (result.success) { + res.json(result); + } else { + // Use 200 for execution errors (client can handle based on success field) + // Use 4xx/5xx for request-level errors + res.json(result); + } + } catch (error) { + console.error(`[${new Date().toISOString()}] Execute endpoint error:`, error); + res.status(500).json({ + success: false, + error: "Internal server error", + }); } - } catch (error) { - console.error(`[${new Date().toISOString()}] Execute endpoint error:`, error); - res.status(500).json({ - success: false, - error: "Internal server error", - }); - } - }); + }, + ); console.log("Python execution routes configured:"); console.log(` - POST /api/execute`); diff --git a/server/server.js b/server/server.js index 91bf1d1..15621b3 100644 --- a/server/server.js +++ b/server/server.js @@ -5,19 +5,31 @@ const express = require("express"); const cors = require("cors"); const WebSocket = require("ws"); const url = require("url"); +const { + ALLOWED_SCOPES, + API_TOKEN_TTL_SECONDS, + AUTH_TOKEN, + issueShortLivedToken, +} = require("./auth"); const { setupFileRoutes, getOpenAPISpec } = require("./fileStorage"); const { setupPythonRoutes } = require("./pythonExecutor"); +const { setupTurnCredentialRoutes } = require("./turnCredentials"); const PORT = process.env.PORT || 4444; -const AUTH_TOKEN = process.env.AUTH_TOKEN; const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",") : ["https://gdsjam.com", "http://localhost:5173", "http://localhost:4173"]; const RATE_LIMIT_WINDOW = parseInt(process.env.RATE_LIMIT_WINDOW || "60000", 10); // 1 minute default const RATE_LIMIT_MAX_CONNECTIONS = parseInt(process.env.RATE_LIMIT_MAX_CONNECTIONS || "10", 10); // Max connections per IP per window +const API_TOKEN_RATE_LIMIT_WINDOW = parseInt( + process.env.API_TOKEN_RATE_LIMIT_WINDOW || "60000", + 10, +); +const API_TOKEN_RATE_LIMIT_MAX = parseInt(process.env.API_TOKEN_RATE_LIMIT_MAX || "30", 10); // Rate limiting: Track connections per IP const connectionTracker = new Map(); +const apiTokenTracker = new Map(); // Room management: Track which clients are in which rooms // Map> @@ -38,12 +50,61 @@ app.use( app.use(express.json()); app.use(express.urlencoded({ extended: true })); +function rateLimitApiTokenIssuance(req, res, next) { + const clientIp = req.ip || req.socket.remoteAddress; + const now = Date.now(); + + if (!apiTokenTracker.has(clientIp)) { + apiTokenTracker.set(clientIp, []); + } + + const attempts = apiTokenTracker.get(clientIp); + const recentAttempts = attempts.filter( + (timestamp) => now - timestamp < API_TOKEN_RATE_LIMIT_WINDOW, + ); + if (recentAttempts.length >= API_TOKEN_RATE_LIMIT_MAX) { + return res.status(429).json({ + error: `Rate limit exceeded. Maximum ${API_TOKEN_RATE_LIMIT_MAX} token requests per minute.`, + }); + } + + recentAttempts.push(now); + apiTokenTracker.set(clientIp, recentAttempts); + next(); +} + +/** + * Short-lived scoped API token issuance. + * Used by browser clients to avoid embedding long-lived AUTH_TOKEN in bundles. + */ +app.post("/api/auth/token", rateLimitApiTokenIssuance, (req, res) => { + try { + if (!AUTH_TOKEN) { + return res.status(503).json({ + error: "Token authentication is disabled on this server", + }); + } + + const requestedScopes = Array.isArray(req.body?.scopes) ? req.body.scopes : []; + const token = issueShortLivedToken(req, requestedScopes); + res.json(token); + } catch (error) { + return res.status(400).json({ + error: error instanceof Error ? error.message : "Failed to issue token", + allowedScopes: Array.from(ALLOWED_SCOPES), + }); + } +}); + // Setup file storage routes setupFileRoutes(app); // Setup Python execution routes setupPythonRoutes(app); +// Setup TURN credential routes +setupTurnCredentialRoutes(app); + // OpenAPI documentation endpoints app.get("/api/openapi.json", (req, res) => { res.json(getOpenAPISpec()); @@ -282,6 +343,10 @@ server.listen(PORT, "0.0.0.0", () => { console.log( ` - Token auth: ${AUTH_TOKEN ? "ENABLED" : "DISABLED (WARNING: anyone can connect!)"}`, ); + console.log(` - API token TTL: ${API_TOKEN_TTL_SECONDS}s`); + console.log( + ` - API token issue limit: ${API_TOKEN_RATE_LIMIT_MAX} per ${API_TOKEN_RATE_LIMIT_WINDOW / 1000}s per IP`, + ); console.log(` - Origin checking: ENABLED (${ALLOWED_ORIGINS.length} allowed origins)`); console.log( ` - Rate limiting: ${RATE_LIMIT_MAX_CONNECTIONS} connections per IP per ${RATE_LIMIT_WINDOW / 1000}s`, diff --git a/server/turnCredentials.js b/server/turnCredentials.js new file mode 100644 index 0000000..a3d5713 --- /dev/null +++ b/server/turnCredentials.js @@ -0,0 +1,57 @@ +const crypto = require("node:crypto"); +const { authenticateRequest } = require("./auth"); + +const TURN_SHARED_SECRET = process.env.TURN_SHARED_SECRET || ""; +const TURN_REALM = process.env.TURN_REALM || "signaling.gdsjam.com"; +const TURN_TTL_SECONDS = parseInt(process.env.TURN_TTL_SECONDS || "600", 10); // 10 minutes +const TURN_USERNAME_PREFIX = process.env.TURN_USERNAME_PREFIX || "gdsjam"; +const TURN_URLS = process.env.TURN_URLS + ? process.env.TURN_URLS.split(",") + .map((u) => u.trim()) + .filter(Boolean) + : [ + "turn:signaling.gdsjam.com:3478", + "turn:signaling.gdsjam.com:3478?transport=tcp", + "turns:signaling.gdsjam.com:5349?transport=tcp", + ]; + +function createTurnCredential(username) { + // coturn REST API auth expects base64(HMAC-SHA1(secret, username)) + return crypto.createHmac("sha1", TURN_SHARED_SECRET).update(username).digest("base64"); +} + +function generateTurnCredentials() { + if (!TURN_SHARED_SECRET) { + throw new Error("TURN_SHARED_SECRET is not configured"); + } + const expiresAt = Math.floor(Date.now() / 1000) + TURN_TTL_SECONDS; + const nonce = crypto.randomBytes(6).toString("hex"); + const username = `${expiresAt}:${TURN_USERNAME_PREFIX}-${nonce}`; + const credential = createTurnCredential(username); + return { + urls: TURN_URLS, + username, + credential, + realm: TURN_REALM, + ttl: TURN_TTL_SECONDS, + expiresAt: expiresAt * 1000, + }; +} + +function setupTurnCredentialRoutes(app) { + app.get("/api/turn-credentials", authenticateRequest(["turn:read"]), (_req, res) => { + try { + const turnCreds = generateTurnCredentials(); + return res.json(turnCreds); + } catch (error) { + return res.status(503).json({ + error: error instanceof Error ? error.message : "TURN credential service unavailable", + }); + } + }); + + console.log("TURN credential routes configured:"); + console.log(" - GET /api/turn-credentials"); +} + +module.exports = { setupTurnCredentialRoutes }; diff --git a/src/components/viewer/ViewerCanvas.svelte b/src/components/viewer/ViewerCanvas.svelte index 1a5f420..1131c33 100644 --- a/src/components/viewer/ViewerCanvas.svelte +++ b/src/components/viewer/ViewerCanvas.svelte @@ -62,6 +62,7 @@ let layerStoreInitialized = false; let keyModeController: ViewerKeyModeController | null = null; let measurementController: ViewerMeasurementController | null = null; let commentController: ViewerCommentController | null = null; +let viewerResizeContainer: HTMLElement | null = null; // Comment mode state let commentModeActive = $state(false); @@ -312,6 +313,10 @@ function handleMeasurementTouchEnd(event: TouchEvent): void { measurementController?.handleTouchEnd(event); } +function handleViewerResize(): void { + renderer?.triggerResize(); +} + /** * Handle Ctrl/Cmd+K to clear all measurements (KLayout-style) */ @@ -440,11 +445,9 @@ onMount(() => { canvas.addEventListener("mousemove", handleMouseMove); // Listen for custom resize event from EditorLayout - const viewerContainer = canvas.parentElement; - if (viewerContainer) { - viewerContainer.addEventListener("viewer-resize", () => { - renderer?.triggerResize(); - }); + viewerResizeContainer = canvas.parentElement; + if (viewerResizeContainer) { + viewerResizeContainer.addEventListener("viewer-resize", handleViewerResize); } // Initialize renderer asynchronously @@ -528,6 +531,12 @@ onMount(() => { // Remove mouse move handler canvas.removeEventListener("mousemove", handleMouseMove); + // Remove custom resize event listener + if (viewerResizeContainer) { + viewerResizeContainer.removeEventListener("viewer-resize", handleViewerResize); + viewerResizeContainer = null; + } + keyModeController?.destroy(); keyModeController = null; measurementController = null; diff --git a/src/lib/api/authTokenClient.ts b/src/lib/api/authTokenClient.ts new file mode 100644 index 0000000..bab078f --- /dev/null +++ b/src/lib/api/authTokenClient.ts @@ -0,0 +1,58 @@ +interface ShortLivedTokenResponse { + token: string; + expiresAt: number; + expiresIn: number; + scopes: string[]; +} + +interface CachedToken { + token: string; + expiresAt: number; +} + +const tokenCache = new Map(); +const TOKEN_REFRESH_BUFFER_MS = 30_000; // refresh 30s before expiry + +function buildCacheKey(baseUrl: string, scopes: string[]): string { + return `${baseUrl}|${scopes.slice().sort().join(",")}`; +} + +/** + * Get a short-lived scoped API token from the server. + * Tokens are cached in-memory and refreshed before expiration. + */ +export async function getShortLivedApiToken(baseUrl: string, scopes: string[]): Promise { + const cacheKey = buildCacheKey(baseUrl, scopes); + const now = Date.now(); + const cached = tokenCache.get(cacheKey); + + if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > now) { + return cached.token; + } + + const response = await fetch(`${baseUrl}/api/auth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scopes }), + }); + + if (!response.ok) { + // Backward compatibility: auth-disabled or older server. + if (response.status === 404 || response.status === 503) { + return ""; + } + throw new Error(`Token request failed: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as ShortLivedTokenResponse; + if (!result.token || !result.expiresAt) { + throw new Error("Invalid token response from server"); + } + + tokenCache.set(cacheKey, { + token: result.token, + expiresAt: result.expiresAt, + }); + + return result.token; +} diff --git a/src/lib/api/pythonExecutor.ts b/src/lib/api/pythonExecutor.ts index 472fa53..bb6069a 100644 --- a/src/lib/api/pythonExecutor.ts +++ b/src/lib/api/pythonExecutor.ts @@ -5,6 +5,8 @@ * Executes Python/gdsfactory code and returns generated GDS files. */ +import { getShortLivedApiToken } from "./authTokenClient"; + export interface ExecutionResult { success: boolean; stdout: string; @@ -18,11 +20,9 @@ export interface ExecutionResult { export class PythonExecutor { private baseUrl: string; - private token: string; constructor() { this.baseUrl = import.meta.env.VITE_FILE_SERVER_URL || ""; - this.token = import.meta.env.VITE_FILE_SERVER_TOKEN || ""; // Validate that required environment variables are set if (!this.baseUrl) { @@ -49,11 +49,12 @@ export class PythonExecutor { } try { + const apiToken = await getShortLivedApiToken(this.baseUrl, ["python:execute"]); const response = await fetch(`${this.baseUrl}/api/execute`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${apiToken}`, }, body: JSON.stringify({ code }), }); @@ -119,13 +120,14 @@ export class PythonExecutor { */ async validateServer(): Promise { try { + const apiToken = await getShortLivedApiToken(this.baseUrl, ["python:execute"]); // Simple fetch to check if server is reachable // We'll just try to execute an empty script (will fail but confirms server is up) const response = await fetch(`${this.baseUrl}/api/execute`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${apiToken}`, }, body: JSON.stringify({ code: "" }), }); @@ -144,10 +146,11 @@ export class PythonExecutor { * @returns ArrayBuffer of the GDS file */ async downloadFile(fileId: string): Promise { + const apiToken = await getShortLivedApiToken(this.baseUrl, ["files:read"]); const response = await fetch(`${this.baseUrl}/api/files/${fileId}`, { method: "GET", headers: { - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${apiToken}`, }, }); diff --git a/src/lib/api/turnCredentialsClient.ts b/src/lib/api/turnCredentialsClient.ts new file mode 100644 index 0000000..a17cde0 --- /dev/null +++ b/src/lib/api/turnCredentialsClient.ts @@ -0,0 +1,51 @@ +import { getShortLivedApiToken } from "./authTokenClient"; + +export interface TurnCredentialsResponse { + urls: string[]; + username: string; + credential: string; + realm?: string; + ttl: number; + expiresAt: number; +} + +let cachedTurnCredentials: TurnCredentialsResponse | null = null; +const TURN_REFRESH_BUFFER_MS = 30_000; + +export async function getTurnCredentials(baseUrl: string): Promise { + const now = Date.now(); + if ( + cachedTurnCredentials && + cachedTurnCredentials.expiresAt - TURN_REFRESH_BUFFER_MS > now && + cachedTurnCredentials.urls.length > 0 + ) { + return cachedTurnCredentials; + } + + const apiToken = await getShortLivedApiToken(baseUrl, ["turn:read"]); + const headers: HeadersInit = {}; + if (apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + + const response = await fetch(`${baseUrl}/api/turn-credentials`, { + method: "GET", + headers, + }); + + if (!response.ok) { + // Backward compatibility for older servers. + if (response.status === 404 || response.status === 503 || response.status === 401) { + return null; + } + throw new Error(`TURN credential request failed: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as TurnCredentialsResponse; + if (!result.username || !result.credential || !Array.isArray(result.urls)) { + throw new Error("Invalid TURN credential response"); + } + + cachedTurnCredentials = result; + return result; +} diff --git a/src/lib/collaboration/FileTransfer.ts b/src/lib/collaboration/FileTransfer.ts index 53cb0a4..2712aef 100644 --- a/src/lib/collaboration/FileTransfer.ts +++ b/src/lib/collaboration/FileTransfer.ts @@ -10,6 +10,7 @@ */ import type * as Y from "yjs"; +import { getShortLivedApiToken } from "../api/authTokenClient"; import { computeSHA256 } from "../utils/hash"; import type { CollaborationEvent, SessionMetadata } from "./types"; @@ -43,7 +44,7 @@ export class FileTransfer { // Upload file to server const fileServerUrl = import.meta.env.VITE_FILE_SERVER_URL || "https://signaling.gdsjam.com"; - const fileServerToken = import.meta.env.VITE_FILE_SERVER_TOKEN; + const apiToken = await getShortLivedApiToken(fileServerUrl, ["files:write"]); const formData = new FormData(); formData.append("file", new Blob([arrayBuffer])); @@ -51,7 +52,7 @@ export class FileTransfer { const response = await fetch(`${fileServerUrl}/api/files`, { method: "POST", headers: { - Authorization: `Bearer ${fileServerToken}`, + Authorization: `Bearer ${apiToken}`, }, body: formData, }); @@ -103,11 +104,11 @@ export class FileTransfer { // Download file from server with retry logic const fileServerUrl = import.meta.env.VITE_FILE_SERVER_URL || "https://signaling.gdsjam.com"; - const fileServerToken = import.meta.env.VITE_FILE_SERVER_TOKEN; + const apiToken = await getShortLivedApiToken(fileServerUrl, ["files:read"]); const arrayBuffer = await this.downloadWithRetry( `${fileServerUrl}/api/files/${fileId}`, - fileServerToken, + apiToken, 3, ); @@ -146,11 +147,11 @@ export class FileTransfer { this.onProgress?.(0, "Recovering file from server..."); const fileServerUrl = import.meta.env.VITE_FILE_SERVER_URL || "https://signaling.gdsjam.com"; - const fileServerToken = import.meta.env.VITE_FILE_SERVER_TOKEN; + const apiToken = await getShortLivedApiToken(fileServerUrl, ["files:read"]); const arrayBuffer = await this.downloadWithRetry( `${fileServerUrl}/api/files/${fileId}`, - fileServerToken, + apiToken, 3, ); diff --git a/src/lib/collaboration/SessionManager.ts b/src/lib/collaboration/SessionManager.ts index 35a2ebd..65ea203 100644 --- a/src/lib/collaboration/SessionManager.ts +++ b/src/lib/collaboration/SessionManager.ts @@ -9,6 +9,7 @@ * - Coordinate HostManager and ParticipantManager (facade pattern) */ +import { getShortLivedApiToken } from "../api/authTokenClient"; import { generateUUID } from "../utils/uuid"; import { CommentSync, type CommentSyncCallbacks } from "./CommentSync"; import { FileTransfer } from "./FileTransfer"; @@ -111,7 +112,7 @@ export class SessionManager { // Upload file to server const fileServerUrl = import.meta.env.VITE_FILE_SERVER_URL || "https://signaling.gdsjam.com"; - const fileServerToken = import.meta.env.VITE_FILE_SERVER_TOKEN; + const apiToken = await getShortLivedApiToken(fileServerUrl, ["files:write"]); const formData = new FormData(); formData.append("file", new Blob([this.pendingFile.arrayBuffer])); @@ -119,7 +120,7 @@ export class SessionManager { const response = await fetch(`${fileServerUrl}/api/files`, { method: "POST", headers: { - Authorization: `Bearer ${fileServerToken}`, + Authorization: `Bearer ${apiToken}`, }, body: formData, }); @@ -143,7 +144,7 @@ export class SessionManager { window.history.pushState({}, "", url.toString()); // Connect to Y.js room - this.yjsProvider.connect(sessionId); + await this.yjsProvider.connect(sessionId); // Initialize managers for this session this.hostManager.initialize(sessionId); @@ -303,7 +304,7 @@ export class SessionManager { // Connect to Y.js room - no need to wait for sync // Host has metadata in localStorage, file is on signaling server - this.yjsProvider.connect(sessionId); + await this.yjsProvider.connect(sessionId); // Write session data to Y.js (host is authoritative for metadata) this.yjsProvider.getDoc().transact(() => { @@ -363,7 +364,7 @@ export class SessionManager { this.participantManager.setLocalAwarenessState({ isHost: false }); // Connect to Y.js room - this.yjsProvider.connect(sessionId); + await this.yjsProvider.connect(sessionId); // Wait for Y.js to sync with peers before any document writes await this.yjsProvider.waitForSync(5000); diff --git a/src/lib/collaboration/YjsProvider.ts b/src/lib/collaboration/YjsProvider.ts index 8dce2b6..a352111 100644 --- a/src/lib/collaboration/YjsProvider.ts +++ b/src/lib/collaboration/YjsProvider.ts @@ -11,6 +11,7 @@ import { Awareness } from "y-protocols/awareness.js"; import { WebrtcProvider } from "y-webrtc"; import * as Y from "yjs"; +import { getTurnCredentials } from "../api/turnCredentialsClient"; import { DEBUG } from "../config"; import type { CollaborationEventCallback } from "./types"; @@ -37,7 +38,7 @@ export class YjsProvider { /** * Connect to a Y.js room via WebRTC */ - connect(roomName: string): void { + async connect(roomName: string): Promise { // If already connected to this room, do nothing if (this.provider && this.roomName === roomName) { return; @@ -61,9 +62,6 @@ export class YjsProvider { ? `${signalingUrl}?token=${signalingToken}` : signalingUrl; - // Load TURN server credentials from environment - const turnPassword = import.meta.env.VITE_TURN_PASSWORD; - // Build ICE servers configuration const iceServers: RTCIceServer[] = [ // STUN servers for NAT discovery @@ -72,17 +70,29 @@ export class YjsProvider { { urls: "stun:stun2.l.google.com:19302" }, ]; - // Add TURN server if credentials are available - if (turnPassword) { + // Preferred path: fetch short-lived TURN credentials from server. + const turnApiBaseUrl = import.meta.env.VITE_FILE_SERVER_URL || "https://signaling.gdsjam.com"; + const turnCredentials = await getTurnCredentials(turnApiBaseUrl).catch(() => null); + if (turnCredentials && turnCredentials.urls.length > 0) { iceServers.push({ - urls: [ - "turn:signaling.gdsjam.com:3478", - "turn:signaling.gdsjam.com:3478?transport=tcp", - "turns:signaling.gdsjam.com:5349?transport=tcp", - ], - username: "gdsjam", - credential: turnPassword, + urls: turnCredentials.urls, + username: turnCredentials.username, + credential: turnCredentials.credential, }); + } else { + // Fallback path for legacy/local setups still using static TURN credentials. + const turnPassword = import.meta.env.VITE_TURN_PASSWORD; + if (turnPassword) { + iceServers.push({ + urls: [ + "turn:signaling.gdsjam.com:3478", + "turn:signaling.gdsjam.com:3478?transport=tcp", + "turns:signaling.gdsjam.com:5349?transport=tcp", + ], + username: "gdsjam", + credential: turnPassword, + }); + } } this.provider = new WebrtcProvider(roomName, this.ydoc, { diff --git a/src/lib/renderer/PixiRenderer.ts b/src/lib/renderer/PixiRenderer.ts index daddcec..fbf9209 100644 --- a/src/lib/renderer/PixiRenderer.ts +++ b/src/lib/renderer/PixiRenderer.ts @@ -98,6 +98,7 @@ export class PixiRenderer { // Callback for when viewport interaction is blocked (for showing toast) private onViewportBlockedCallback: (() => void) | null = null; + private readonly layerVisibilityChangeHandler: EventListener; // Flag to prevent viewport changes when following host private isViewportLocked = false; @@ -140,6 +141,9 @@ export class PixiRenderer { fill: 0xcccccc, }, }); + + // Use a stable function reference so add/removeEventListener pair correctly. + this.layerVisibilityChangeHandler = this.handleLayerVisibilityChange.bind(this); } /** @@ -193,7 +197,7 @@ export class PixiRenderer { this.coordinatesDisplay = new CoordinatesDisplay(this.coordsText); this.gridOverlay = new GridOverlay(this.gridContainer, this.app); this.scaleBarOverlay = new ScaleBarOverlay(this.scaleBarContainer, this.app); - this.measurementOverlay = new MeasurementOverlay(this.measurementContainer, this.app); + this.measurementOverlay = new MeasurementOverlay(this.measurementContainer); this.app.ticker.add(this.onTick.bind(this)); this.mainContainer.eventMode = "static"; @@ -244,10 +248,7 @@ export class PixiRenderer { this.performScaleBarUpdate(); // Listen for layer visibility changes - window.addEventListener( - "layer-visibility-changed", - this.handleLayerVisibilityChange.bind(this), - ); + window.addEventListener("layer-visibility-changed", this.layerVisibilityChangeHandler); } /** @@ -994,10 +995,7 @@ export class PixiRenderer { } // Remove event listener - window.removeEventListener( - "layer-visibility-changed", - this.handleLayerVisibilityChange.bind(this), - ); + window.removeEventListener("layer-visibility-changed", this.layerVisibilityChangeHandler); this.app.destroy(true, { children: true, texture: true }); } diff --git a/src/lib/renderer/overlays/MeasurementOverlay.ts b/src/lib/renderer/overlays/MeasurementOverlay.ts index 082edef..74cd8bc 100644 --- a/src/lib/renderer/overlays/MeasurementOverlay.ts +++ b/src/lib/renderer/overlays/MeasurementOverlay.ts @@ -9,18 +9,16 @@ * - Handle viewport changes (measurements stay anchored to world coordinates) */ -import type { Application, Container } from "pixi.js"; +import type { Container } from "pixi.js"; import { Graphics, Text } from "pixi.js"; import type { ActiveMeasurement, DistanceMeasurement } from "../../measurements/types"; import { formatDistance, worldToScreen } from "../../measurements/utils"; export class MeasurementOverlay { private container: Container; - private app: Application; - constructor(container: Container, app: Application) { + constructor(container: Container) { this.container = container; - this.app = app; } /** diff --git a/tests/api/authTokenClient.test.ts b/tests/api/authTokenClient.test.ts new file mode 100644 index 0000000..1bfcad9 --- /dev/null +++ b/tests/api/authTokenClient.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authTokenClient", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("caches token by baseUrl+scopes and avoids duplicate fetch", async () => { + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + token: "token-1", + expiresAt: now + 300_000, + expiresIn: 300, + scopes: ["files:read"], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { getShortLivedApiToken } = await import("../../src/lib/api/authTokenClient"); + const t1 = await getShortLivedApiToken("https://api.test", ["files:read"]); + const t2 = await getShortLivedApiToken("https://api.test", ["files:read"]); + + expect(t1).toBe("token-1"); + expect(t2).toBe("token-1"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refreshes token when near expiry", async () => { + const now = 2_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "short-lived", + expiresAt: now + 10_000, + expiresIn: 10, + scopes: ["files:read"], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "refreshed", + expiresAt: now + 300_000, + expiresIn: 300, + scopes: ["files:read"], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { getShortLivedApiToken } = await import("../../src/lib/api/authTokenClient"); + const t1 = await getShortLivedApiToken("https://api.test", ["files:read"]); + const t2 = await getShortLivedApiToken("https://api.test", ["files:read"]); + + expect(t1).toBe("short-lived"); + expect(t2).toBe("refreshed"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns empty token on 404/503 compatibility path", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }) + .mockResolvedValueOnce({ ok: false, status: 503, statusText: "Unavailable" }); + vi.stubGlobal("fetch", fetchMock); + + const { getShortLivedApiToken } = await import("../../src/lib/api/authTokenClient"); + const t1 = await getShortLivedApiToken("https://api.test", ["files:read"]); + const t2 = await getShortLivedApiToken("https://api.test", ["files:write"]); + + expect(t1).toBe(""); + expect(t2).toBe(""); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/api/pythonExecutor.auth.test.ts b/tests/api/pythonExecutor.auth.test.ts new file mode 100644 index 0000000..8f5bfec --- /dev/null +++ b/tests/api/pythonExecutor.auth.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PythonExecutor } from "../../src/lib/api/pythonExecutor"; + +vi.mock("../../src/lib/api/authTokenClient", () => ({ + getShortLivedApiToken: vi.fn(), +})); + +describe("PythonExecutor auth scopes", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("uses python:execute token for execute()", async () => { + const authModule = await import("../../src/lib/api/authTokenClient"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue("py-token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + json: async () => ({ success: true, stdout: "", stderr: "" }), + }), + ); + + const executor = new PythonExecutor(); + (executor as any).baseUrl = "https://api.test"; + await executor.execute("print('ok')"); + + expect(authModule.getShortLivedApiToken).toHaveBeenCalledWith("https://api.test", [ + "python:execute", + ]); + const fetchMock = vi.mocked(fetch); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer py-token", + }, + }); + }); + + it("uses files:read token for downloadFile()", async () => { + const authModule = await import("../../src/lib/api/authTokenClient"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue("read-token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([1, 2]).buffer, + }), + ); + + const executor = new PythonExecutor(); + (executor as any).baseUrl = "https://api.test"; + await executor.downloadFile("file-id"); + + expect(authModule.getShortLivedApiToken).toHaveBeenCalledWith("https://api.test", [ + "files:read", + ]); + const fetchMock = vi.mocked(fetch); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "GET", + headers: { Authorization: "Bearer read-token" }, + }); + }); +}); diff --git a/tests/api/turnCredentialsClient.test.ts b/tests/api/turnCredentialsClient.test.ts new file mode 100644 index 0000000..8b70e40 --- /dev/null +++ b/tests/api/turnCredentialsClient.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/lib/api/authTokenClient", () => ({ + getShortLivedApiToken: vi.fn(), +})); + +describe("turnCredentialsClient", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("fetches TURN credentials with turn:read token and caches response", async () => { + const now = 5_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + urls: ["turn:turn.example.com:3478"], + username: "u", + credential: "c", + ttl: 600, + expiresAt: now + 600_000, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const authModule = await import("../../src/lib/api/authTokenClient"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue("ephemeral-token"); + + const { getTurnCredentials } = await import("../../src/lib/api/turnCredentialsClient"); + const c1 = await getTurnCredentials("https://api.test"); + const c2 = await getTurnCredentials("https://api.test"); + + expect(authModule.getShortLivedApiToken).toHaveBeenCalledWith("https://api.test", [ + "turn:read", + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "GET", + headers: { Authorization: "Bearer ephemeral-token" }, + }); + expect(c1?.username).toBe("u"); + expect(c2?.username).toBe("u"); + }); + + it("returns null on compatibility status codes", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 401, statusText: "Unauthorized" }) + .mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }) + .mockResolvedValueOnce({ ok: false, status: 503, statusText: "Unavailable" }); + vi.stubGlobal("fetch", fetchMock); + + const authModule = await import("../../src/lib/api/authTokenClient"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue(""); + + const { getTurnCredentials } = await import("../../src/lib/api/turnCredentialsClient"); + expect(await getTurnCredentials("https://api.test")).toBeNull(); + expect(await getTurnCredentials("https://api.test")).toBeNull(); + expect(await getTurnCredentials("https://api.test")).toBeNull(); + }); +}); diff --git a/tests/collaboration/FileTransfer.auth.test.ts b/tests/collaboration/FileTransfer.auth.test.ts new file mode 100644 index 0000000..2e020b8 --- /dev/null +++ b/tests/collaboration/FileTransfer.auth.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { FileTransfer } from "../../src/lib/collaboration/FileTransfer"; + +vi.mock("../../src/lib/api/authTokenClient", () => ({ + getShortLivedApiToken: vi.fn(), +})); + +vi.mock("../../src/lib/utils/hash", () => ({ + computeSHA256: vi.fn(), +})); + +describe("FileTransfer auth scopes", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("uses files:write token for upload", async () => { + const authModule = await import("../../src/lib/api/authTokenClient"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue("write-token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ fileId: "abc" }), + }), + ); + + const ydoc = new Y.Doc(); + const transfer = new FileTransfer(ydoc); + await transfer.uploadFile(new Uint8Array([1, 2, 3]).buffer, "demo.gds", "user-1"); + + expect(authModule.getShortLivedApiToken).toHaveBeenCalledWith("https://signaling.gdsjam.com", [ + "files:write", + ]); + const fetchMock = vi.mocked(fetch); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + headers: { Authorization: "Bearer write-token" }, + }); + }); + + it("uses files:read token for download", async () => { + const authModule = await import("../../src/lib/api/authTokenClient"); + const hashModule = await import("../../src/lib/utils/hash"); + vi.mocked(authModule.getShortLivedApiToken).mockResolvedValue("read-token"); + vi.mocked(hashModule.computeSHA256).mockResolvedValue("expected-hash"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([4, 5, 6]).buffer, + }), + ); + + const ydoc = new Y.Doc(); + const sessionMap = ydoc.getMap("session"); + sessionMap.set("fileId", "f123"); + sessionMap.set("fileName", "demo.gds"); + sessionMap.set("fileHash", "expected-hash"); + + const transfer = new FileTransfer(ydoc); + await transfer.downloadFile(); + + expect(authModule.getShortLivedApiToken).toHaveBeenCalledWith("https://signaling.gdsjam.com", [ + "files:read", + ]); + const fetchMock = vi.mocked(fetch); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer read-token" }, + }); + }); +}); diff --git a/tests/collaboration/YjsProvider.turn.test.ts b/tests/collaboration/YjsProvider.turn.test.ts new file mode 100644 index 0000000..4fd0602 --- /dev/null +++ b/tests/collaboration/YjsProvider.turn.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const providerSpy = vi.fn(); + +vi.mock("y-webrtc", () => { + class MockWebrtcProvider { + public on = vi.fn(); + public off = vi.fn(); + public destroy = vi.fn(); + public synced = false; + + constructor(...args: unknown[]) { + providerSpy(...args); + } + } + return { WebrtcProvider: MockWebrtcProvider }; +}); + +vi.mock("../../src/lib/api/turnCredentialsClient", () => ({ + getTurnCredentials: vi.fn(), +})); + +describe("YjsProvider TURN configuration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + providerSpy.mockClear(); + }); + + it("uses ephemeral TURN credentials when available", async () => { + const turnModule = await import("../../src/lib/api/turnCredentialsClient"); + vi.mocked(turnModule.getTurnCredentials).mockResolvedValue({ + urls: ["turn:turn.example.com:3478"], + username: "user1", + credential: "cred1", + ttl: 600, + expiresAt: Date.now() + 600_000, + }); + + const { YjsProvider } = await import("../../src/lib/collaboration/YjsProvider"); + const y = new YjsProvider("user-1"); + await y.connect("room-1"); + + expect(turnModule.getTurnCredentials).toHaveBeenCalled(); + expect(providerSpy).toHaveBeenCalledTimes(1); + const options = providerSpy.mock.calls[0]?.[2] as any; + const iceServers = options.peerOpts.config.iceServers as RTCIceServer[]; + expect(iceServers.some((s) => s.urls === "stun:stun.l.google.com:19302")).toBe(true); + expect( + iceServers.some((s) => + Array.isArray(s.urls) + ? s.urls.includes("turn:turn.example.com:3478") + : s.urls === "turn:turn.example.com:3478", + ), + ).toBe(true); + expect(iceServers.find((s) => s.username === "user1")?.credential).toBe("cred1"); + }); +}); diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts new file mode 100644 index 0000000..4f41161 --- /dev/null +++ b/tests/server/auth.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createRes() { + return { + statusCode: 200, + body: null as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; +} + +describe("server/auth middleware", () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.AUTH_TOKEN; + delete process.env.API_TOKEN_SECRET; + }); + + it("allows requests when AUTH_TOKEN is disabled", async () => { + const auth = await import("../../server/auth.js"); + const req: any = { headers: {}, ip: "1.1.1.1", socket: { remoteAddress: "1.1.1.1" } }; + const res = createRes(); + const next = vi.fn(); + + auth.authenticateRequest(["files:read"])(req, res as any, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("accepts valid short-lived token with required scope", async () => { + process.env.AUTH_TOKEN = "master-token"; + process.env.API_TOKEN_SECRET = "secret-123"; + const auth = await import("../../server/auth.js"); + const issueReq: any = { + headers: {}, + ip: "2.2.2.2", + socket: { remoteAddress: "2.2.2.2" }, + }; + const { token } = auth.issueShortLivedToken(issueReq, ["files:read"]); + + const req: any = { + headers: { authorization: `Bearer ${token}` }, + ip: "2.2.2.2", + socket: { remoteAddress: "2.2.2.2" }, + }; + const res = createRes(); + const next = vi.fn(); + + auth.authenticateRequest(["files:read"])(req, res as any, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("rejects token with insufficient scope", async () => { + process.env.AUTH_TOKEN = "master-token"; + process.env.API_TOKEN_SECRET = "secret-123"; + const auth = await import("../../server/auth.js"); + const issueReq: any = { + headers: {}, + ip: "3.3.3.3", + socket: { remoteAddress: "3.3.3.3" }, + }; + const { token } = auth.issueShortLivedToken(issueReq, ["files:read"]); + const req: any = { + headers: { authorization: `Bearer ${token}` }, + ip: "3.3.3.3", + socket: { remoteAddress: "3.3.3.3" }, + }; + const res = createRes(); + const next = vi.fn(); + + auth.authenticateRequest(["python:execute"])(req, res as any, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Insufficient token scope" }); + }); + + it("rejects token when request IP does not match token IP", async () => { + process.env.AUTH_TOKEN = "master-token"; + process.env.API_TOKEN_SECRET = "secret-123"; + const auth = await import("../../server/auth.js"); + const issueReq: any = { + headers: {}, + ip: "4.4.4.4", + socket: { remoteAddress: "4.4.4.4" }, + }; + const { token } = auth.issueShortLivedToken(issueReq, ["files:read"]); + const req: any = { + headers: { authorization: `Bearer ${token}` }, + ip: "9.9.9.9", + socket: { remoteAddress: "9.9.9.9" }, + }; + const res = createRes(); + const next = vi.fn(); + + auth.authenticateRequest(["files:read"])(req, res as any, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: "Token IP mismatch" }); + }); +}); diff --git a/tests/server/turnCredentials.test.ts b/tests/server/turnCredentials.test.ts new file mode 100644 index 0000000..6221ba2 --- /dev/null +++ b/tests/server/turnCredentials.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createRes() { + return { + statusCode: 200, + body: null as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; +} + +describe("server/turnCredentials route", () => { + beforeEach(() => { + vi.resetModules(); + process.env.AUTH_TOKEN = "master-token"; + process.env.API_TOKEN_SECRET = "api-secret"; + process.env.TURN_SHARED_SECRET = "turn-secret"; + process.env.TURN_REALM = "signaling.example.com"; + process.env.TURN_TTL_SECONDS = "600"; + process.env.TURN_URLS = "turn:signaling.example.com:3478"; + }); + + it("returns ephemeral TURN credentials through route handler", async () => { + const registered: Record = {}; + const app = { + get: vi.fn((path: string, mw: any, handler: any) => { + registered[path] = { mw, handler }; + }), + }; + const { setupTurnCredentialRoutes } = await import("../../server/turnCredentials.js"); + setupTurnCredentialRoutes(app as any); + + const route = registered["/api/turn-credentials"]; + expect(route).toBeDefined(); + + const req: any = { + headers: { authorization: "Bearer master-token" }, + ip: "1.2.3.4", + socket: { remoteAddress: "1.2.3.4" }, + }; + const res = createRes(); + const next = vi.fn(() => route.handler(req, res)); + + route.mw(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect((res.body as any).urls).toEqual(["turn:signaling.example.com:3478"]); + expect((res.body as any).username).toContain(":"); + expect((res.body as any).credential).toBeTypeOf("string"); + }); +});