add signed request authentication to multiplayer websocket (#8534)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,7 @@ pub fn workspaced_service() -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/sign", post(sign_debug_request))
|
.route("/sign", post(sign_debug_request))
|
||||||
.route("/sign_expression", post(sign_expression))
|
.route("/sign_expression", post(sign_expression))
|
||||||
|
.route("/sign_multiplayer", post(sign_multiplayer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JWKS response containing the public key for debug token verification
|
/// JWKS response containing the public key for debug token verification
|
||||||
@@ -416,3 +417,62 @@ async fn sign_expression(
|
|||||||
|
|
||||||
Ok(Json(SignedExpressionPayload { token }))
|
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<String>,
|
||||||
|
) -> JsonResult<SignedMultiplayerPayload> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
getDebugFileExtension,
|
getDebugFileExtension,
|
||||||
fetchContextualVariables,
|
fetchContextualVariables,
|
||||||
signDebugRequest,
|
signDebugRequest,
|
||||||
|
signMultiplayerRequest,
|
||||||
getDebugErrorMessage
|
getDebugErrorMessage
|
||||||
} from '$lib/components/debug'
|
} from '$lib/components/debug'
|
||||||
import { SvelteSet } from 'svelte/reactivity'
|
import { SvelteSet } from 'svelte/reactivity'
|
||||||
@@ -1111,6 +1112,15 @@
|
|||||||
return
|
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()
|
const ydoc = new Y.Doc()
|
||||||
if (wsProvider) {
|
if (wsProvider) {
|
||||||
wsProvider.destroy()
|
wsProvider.destroy()
|
||||||
@@ -1121,7 +1131,7 @@
|
|||||||
buildWsUrl('/ws_mp/'),
|
buildWsUrl('/ws_mp/'),
|
||||||
$workspaceStore + '/' + (path ?? 'no-room-name'),
|
$workspaceStore + '/' + (path ?? 'no-room-name'),
|
||||||
ydoc,
|
ydoc,
|
||||||
{ connect: false }
|
{ connect: false, params: { token } }
|
||||||
)
|
)
|
||||||
|
|
||||||
wsProvider.on('sync', (isSynced: boolean) => {
|
wsProvider.on('sync', (isSynced: boolean) => {
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import { VariableService, UserService } from '$lib/gen'
|
|||||||
* Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) for the debugger.
|
* Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) for the debugger.
|
||||||
* Creates a fresh short-lived token for the debug session.
|
* Creates a fresh short-lived token for the debug session.
|
||||||
*/
|
*/
|
||||||
export async function fetchContextualVariables(
|
export async function fetchContextualVariables(workspace: string): Promise<Record<string, string>> {
|
||||||
workspace: string
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -77,6 +75,30 @@ export async function signDebugRequest(
|
|||||||
return await response.json()
|
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<string> {
|
||||||
|
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
|
* Get a user-friendly error message for debug errors
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
fetchContextualVariables,
|
fetchContextualVariables,
|
||||||
signDebugRequest,
|
signDebugRequest,
|
||||||
|
signMultiplayerRequest,
|
||||||
getDebugErrorMessage,
|
getDebugErrorMessage,
|
||||||
isDebuggableLanguage,
|
isDebuggableLanguage,
|
||||||
getDebugFileExtension
|
getDebugFileExtension
|
||||||
|
|||||||
@@ -9,14 +9,25 @@
|
|||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { slide } from 'svelte/transition'
|
import { slide } from 'svelte/transition'
|
||||||
import { buildWsUrl } from '$lib/wsUrl'
|
import { buildWsUrl } from '$lib/wsUrl'
|
||||||
|
import { signMultiplayerRequest } from '$lib/components/debug'
|
||||||
|
|
||||||
let awareness: Awareness | undefined = $state(undefined)
|
let awareness: Awareness | undefined = $state(undefined)
|
||||||
let wsProvider: WebsocketProvider | undefined = undefined
|
let wsProvider: WebsocketProvider | undefined = undefined
|
||||||
|
|
||||||
let connected = $state(false)
|
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()
|
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) => {
|
wsProvider.on('sync', (isSynced: boolean) => {
|
||||||
connected = true
|
connected = true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* Run with: node server.mjs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'http'
|
import http from 'http'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
import { WebSocketServer } from 'ws'
|
import { WebSocketServer } from 'ws'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import * as syncProtocol from 'y-protocols/sync'
|
import * as syncProtocol from 'y-protocols/sync'
|
||||||
@@ -14,10 +15,140 @@ import * as decoding from 'lib0/decoding'
|
|||||||
|
|
||||||
const PORT = process.env.PORT || 3002
|
const PORT = process.env.PORT || 3002
|
||||||
const HOST = process.env.HOST || '0.0.0.0'
|
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 messageSync = 0
|
||||||
const messageAwareness = 1
|
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
|
// Store docs in memory
|
||||||
const docs = new Map()
|
const docs = new Map()
|
||||||
|
|
||||||
@@ -121,6 +252,8 @@ const setupWSConnection = (conn, req, docName) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- HTTP + WebSocket server ---
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
// Strip /ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
|
// Strip /ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
|
||||||
if (req.url?.startsWith('/ws_mp/')) {
|
if (req.url?.startsWith('/ws_mp/')) {
|
||||||
@@ -143,7 +276,7 @@ const server = http.createServer((req, res) => {
|
|||||||
|
|
||||||
const wss = new WebSocketServer({ server })
|
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'
|
let docName = req.url?.slice(1).split('?')[0] || 'unknown'
|
||||||
|
|
||||||
// Strip ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
|
// Strip ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
|
||||||
@@ -161,6 +294,26 @@ wss.on('connection', (ws, req) => {
|
|||||||
return
|
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}`)
|
console.log(`[${new Date().toISOString()}] CONNECT: doc="${docName}" from=${clientIp}`)
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
@@ -172,4 +325,9 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`[${new Date().toISOString()}] Multiplayer server running at ${HOST}:${PORT}`)
|
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`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user