diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 4f793e7da7..baece9e8e9 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -87,6 +87,7 @@ pub fn workspaced_service() -> Router { Router::new() .route("/sign", post(sign_debug_request)) .route("/sign_expression", post(sign_expression)) + .route("/sign_multiplayer", post(sign_multiplayer)) } /// JWKS response containing the public key for debug token verification @@ -416,3 +417,62 @@ async fn sign_expression( Ok(Json(SignedExpressionPayload { token })) } + +/// JWT claims for multiplayer session tokens +#[derive(Serialize, Deserialize)] +pub struct MultiplayerTokenClaims { + /// Workspace ID + pub workspace_id: String, + /// User email + pub email: String, + /// Issued at (Unix timestamp) + pub iat: i64, + /// Expiration (Unix timestamp) + pub exp: i64, + /// Token purpose (always "multiplayer") + pub purpose: String, +} + +#[derive(Serialize)] +pub struct SignedMultiplayerPayload { + pub token: String, +} + +/// Sign a multiplayer session request. +/// +/// Returns a JWT that the multiplayer server will verify using the public key from /api/debug/jwks. +async fn sign_multiplayer( + authed: ApiAuthed, + Path(w_id): Path, +) -> JsonResult { + let key_guard = DEBUG_SIGNING_KEY.read().await; + let signing_key = key_guard.as_ref().ok_or_else(|| { + windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string()) + })?; + + let now_ts = Utc::now().timestamp(); + let exp = now_ts + DEBUG_TOKEN_TTL_SECS; + + let claims = MultiplayerTokenClaims { + workspace_id: w_id, + email: authed.email, + iat: now_ts, + exp, + purpose: "multiplayer".to_string(), + }; + + let header = serde_json::json!({ + "alg": "EdDSA", + "typ": "JWT" + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); + let message = format!("{}.{}", header_b64, claims_b64); + + let signature = signing_key.sign(message.as_bytes()); + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let token = format!("{}.{}", message, signature_b64); + + Ok(Json(SignedMultiplayerPayload { token })) +} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 6a4fd088e0..2690301f28 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -67,6 +67,7 @@ getDebugFileExtension, fetchContextualVariables, signDebugRequest, + signMultiplayerRequest, getDebugErrorMessage } from '$lib/components/debug' import { SvelteSet } from 'svelte/reactivity' @@ -1111,6 +1112,15 @@ return } + let token: string | undefined + try { + token = await signMultiplayerRequest($workspaceStore ?? '') + } catch (e) { + console.error('Failed to sign multiplayer request:', e) + sendUserToast('Failed to authorize multiplayer session', true) + return + } + const ydoc = new Y.Doc() if (wsProvider) { wsProvider.destroy() @@ -1121,7 +1131,7 @@ buildWsUrl('/ws_mp/'), $workspaceStore + '/' + (path ?? 'no-room-name'), ydoc, - { connect: false } + { connect: false, params: { token } } ) wsProvider.on('sync', (isSynced: boolean) => { diff --git a/frontend/src/lib/components/debug/debugUtils.ts b/frontend/src/lib/components/debug/debugUtils.ts index ec9c12cfb4..d58fb23983 100644 --- a/frontend/src/lib/components/debug/debugUtils.ts +++ b/frontend/src/lib/components/debug/debugUtils.ts @@ -9,9 +9,7 @@ import { VariableService, UserService } from '$lib/gen' * Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) for the debugger. * Creates a fresh short-lived token for the debug session. */ -export async function fetchContextualVariables( - workspace: string -): Promise> { +export async function fetchContextualVariables(workspace: string): Promise> { if (!workspace) { return {} } @@ -77,6 +75,30 @@ export async function signDebugRequest( return await response.json() } +/** + * Sign a multiplayer session request. Returns a JWT token that the + * multiplayer server will verify before accepting the WebSocket connection. + */ +export async function signMultiplayerRequest(workspace: string): Promise { + if (!workspace) { + throw new Error('No workspace selected') + } + + const response = await fetch(`/api/w/${workspace}/debug/sign_multiplayer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Failed to sign multiplayer session') + } + + const data: { token: string } = await response.json() + return data.token +} + /** * Get a user-friendly error message for debug errors */ diff --git a/frontend/src/lib/components/debug/index.ts b/frontend/src/lib/components/debug/index.ts index 50eb614858..c7397cea74 100644 --- a/frontend/src/lib/components/debug/index.ts +++ b/frontend/src/lib/components/debug/index.ts @@ -61,6 +61,7 @@ export { export { fetchContextualVariables, signDebugRequest, + signMultiplayerRequest, getDebugErrorMessage, isDebuggableLanguage, getDebugFileExtension diff --git a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte index b4665026fb..8697469940 100644 --- a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte +++ b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte @@ -9,14 +9,25 @@ import { page } from '$app/stores' import { slide } from 'svelte/transition' import { buildWsUrl } from '$lib/wsUrl' + import { signMultiplayerRequest } from '$lib/components/debug' let awareness: Awareness | undefined = $state(undefined) let wsProvider: WebsocketProvider | undefined = undefined let connected = $state(false) - function connectWorkspace(workspace: string) { + async function connectWorkspace(workspace: string) { + let token: string | undefined + try { + token = await signMultiplayerRequest(workspace) + } catch (e) { + console.error('Failed to sign multiplayer request:', e) + return + } + const ydoc = new Y.Doc() - wsProvider = new WebsocketProvider(buildWsUrl('/ws_mp/'), workspace, ydoc) + wsProvider = new WebsocketProvider(buildWsUrl('/ws_mp/'), workspace, ydoc, { + params: { token } + }) wsProvider.on('sync', (isSynced: boolean) => { connected = true }) diff --git a/multiplayer/server.mjs b/multiplayer/server.mjs index ed3262068a..8d08133d82 100644 --- a/multiplayer/server.mjs +++ b/multiplayer/server.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node /** - * Simple y-websocket server with connection logging + * Simple y-websocket server with connection logging and JWT authentication. * Run with: node server.mjs */ import http from 'http' +import crypto from 'node:crypto' import { WebSocketServer } from 'ws' import * as Y from 'yjs' import * as syncProtocol from 'y-protocols/sync' @@ -14,10 +15,140 @@ import * as decoding from 'lib0/decoding' const PORT = process.env.PORT || 3002 const HOST = process.env.HOST || '0.0.0.0' +const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL +const REQUIRE_SIGNED_REQUESTS = process.env.REQUIRE_SIGNED_MULTIPLAYER_REQUESTS !== 'false' const messageSync = 0 const messageAwareness = 1 +// --- JWT verification --- + +let cachedPublicKey = null +let publicKeyFetchPromise = null + +function base64urlDecode(str) { + const padding = '='.repeat((4 - str.length % 4) % 4) + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding + const binary = atob(base64) + return Uint8Array.from(binary, c => c.charCodeAt(0)) +} + +async function getPublicKey() { + if (cachedPublicKey) return cachedPublicKey + if (publicKeyFetchPromise) return publicKeyFetchPromise + + if (!WINDMILL_BASE_URL) { + console.warn(`[${new Date().toISOString()}] WINDMILL_BASE_URL not set - cannot fetch public key`) + return null + } + + publicKeyFetchPromise = (async () => { + try { + const jwksUrl = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/jwks` + console.log(`[${new Date().toISOString()}] Fetching JWKS from ${jwksUrl}`) + const response = await fetch(jwksUrl) + if (!response.ok) { + throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + } + + const jwks = await response.json() + if (!jwks.keys || jwks.keys.length === 0) { + throw new Error('No keys in JWKS') + } + + const jwk = jwks.keys[0] + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error(`Unsupported key type: ${jwk.kty}/${jwk.crv}`) + } + + const publicKeyBytes = base64urlDecode(jwk.x) + const key = await crypto.subtle.importKey( + 'raw', + publicKeyBytes, + { name: 'Ed25519' }, + true, + ['verify'] + ) + + cachedPublicKey = key + console.log(`[${new Date().toISOString()}] Successfully loaded Ed25519 public key from JWKS`) + return key + } catch (error) { + console.error(`[${new Date().toISOString()}] Failed to fetch/parse JWKS: ${error}`) + return null + } finally { + publicKeyFetchPromise = null + } + })() + + return publicKeyFetchPromise +} + +/** + * Verify a JWT multiplayer token. + * Returns null if valid, or an error message if invalid. + */ +async function verifyToken(token, docName) { + const publicKey = await getPublicKey() + + if (!publicKey) { + if (REQUIRE_SIGNED_REQUESTS) { + return 'Public key not available but signed requests are required. Set WINDMILL_BASE_URL.' + } + console.warn(`[${new Date().toISOString()}] Public key not available - signature verification disabled`) + return null + } + + const parts = token.split('.') + if (parts.length !== 3) { + return 'Invalid JWT format' + } + + const [headerB64, claimsB64, signatureB64] = parts + + try { + const message = new TextEncoder().encode(`${headerB64}.${claimsB64}`) + const signature = base64urlDecode(signatureB64) + + const isValid = await crypto.subtle.verify( + { name: 'Ed25519' }, + publicKey, + signature, + message + ) + + if (!isValid) { + return 'Invalid JWT signature' + } + + const claimsJson = new TextDecoder().decode(base64urlDecode(claimsB64)) + const claims = JSON.parse(claimsJson) + + // Check expiration + const now = Math.floor(Date.now() / 1000) + if (now > claims.exp) { + return `Token expired: ${now - claims.exp} seconds ago` + } + + // Check purpose + if (claims.purpose !== 'multiplayer') { + return `Invalid token purpose: ${claims.purpose}` + } + + // Check workspace matches the doc room (format: "{workspace}/{path}") + const docWorkspace = docName.split('/')[0] + if (docWorkspace && claims.workspace_id !== docWorkspace) { + return `Token workspace "${claims.workspace_id}" does not match room workspace "${docWorkspace}"` + } + + return null + } catch (error) { + return `JWT verification error: ${error}` + } +} + +// --- Y.js document management --- + // Store docs in memory const docs = new Map() @@ -121,6 +252,8 @@ const setupWSConnection = (conn, req, docName) => { }) } +// --- HTTP + WebSocket server --- + const server = http.createServer((req, res) => { // Strip /ws_mp/ prefix if present (when accessed without reverse proxy path stripping) if (req.url?.startsWith('/ws_mp/')) { @@ -143,7 +276,7 @@ const server = http.createServer((req, res) => { const wss = new WebSocketServer({ server }) -wss.on('connection', (ws, req) => { +wss.on('connection', async (ws, req) => { let docName = req.url?.slice(1).split('?')[0] || 'unknown' // Strip ws_mp/ prefix if present (when accessed without reverse proxy path stripping) @@ -161,6 +294,26 @@ wss.on('connection', (ws, req) => { return } + // Verify JWT token + const urlParams = new URLSearchParams(req.url?.split('?')[1] || '') + const token = urlParams.get('token') + + if (!token) { + if (REQUIRE_SIGNED_REQUESTS) { + console.warn(`[${new Date().toISOString()}] REJECTED: doc="${docName}" from=${clientIp} reason="no token"`) + ws.close(4401, 'Authentication required') + return + } + console.warn(`[${new Date().toISOString()}] WARN: no token for doc="${docName}" from=${clientIp} (signed requests not required)`) + } else { + const error = await verifyToken(token, docName) + if (error) { + console.warn(`[${new Date().toISOString()}] REJECTED: doc="${docName}" from=${clientIp} reason="${error}"`) + ws.close(4403, 'Token verification failed') + return + } + } + console.log(`[${new Date().toISOString()}] CONNECT: doc="${docName}" from=${clientIp}`) ws.on('close', () => { @@ -172,4 +325,9 @@ wss.on('connection', (ws, req) => { server.listen(PORT, HOST, () => { console.log(`[${new Date().toISOString()}] Multiplayer server running at ${HOST}:${PORT}`) + if (REQUIRE_SIGNED_REQUESTS) { + console.log(`[${new Date().toISOString()}] Signed requests REQUIRED (set REQUIRE_SIGNED_MULTIPLAYER_REQUESTS=false to disable)`) + } else { + console.log(`[${new Date().toISOString()}] Signed requests DISABLED`) + } })