/* VDO.Ninja Authentication Client Integration */ // Configuration const AUTH_SERVICE_URL = 'https://vdo-ninja-auth-service.vdo.workers.dev'; // Change for local dev: http://localhost:8787 // Authentication state session.authMode = false; session.requireAuth = false; session.authToken = null; session.authUser = null; session.authStreamMapping = {}; session.handleToStream = {}; // Initialize authentication async function initAuthentication() { // Check URL parameters for universal token first if (urlParams.has("universaltoken")) { session.universalToken = urlParams.get("universaltoken"); session.authMode = true; console.log('Universal token detected:', session.universalToken); // Universal tokens bypass auth requirement for viewing if (session.view || session.scene || session.solo) { session.requireAuth = false; console.log('Auth requirement bypassed for viewing'); } } // Check URL parameters if (urlParams.has("auth") || urlParams.has("requireauth")) { session.authMode = true; session.requireAuth = urlParams.has("requireauth"); // Check for existing auth token in localStorage const storedToken = localStorage.getItem('vdo_auth_token'); if (storedToken) { try { // Validate token is still valid const payload = JSON.parse(atob(storedToken.split('.')[1])); if (payload.exp > Date.now() / 1000) { session.authToken = storedToken; await populateUserInfo(); } else { localStorage.removeItem('vdo_auth_token'); } } catch (e) { localStorage.removeItem('vdo_auth_token'); } } // Check for auth token in URL (after OAuth redirect) if (urlParams.has("authtoken")) { session.authToken = urlParams.get("authtoken"); localStorage.setItem('vdo_auth_token', session.authToken); // Clean URL const url = new URL(window.location.href); url.searchParams.delete('authtoken'); window.history.replaceState({}, document.title, url.toString()); await populateUserInfo(); } // Check if we need to verify room requirements if (!session.authToken && session.authMode && (urlParams.has("room") || urlParams.has("roomid") || urlParams.has("r"))) { const roomId = urlParams.get("room") || urlParams.get("roomid") || urlParams.get("r"); if (roomId) { // Check if this room requires auth try { const roomInfo = await checkRoomAccess(roomId, urlParams.has("director") || urlParams.has("dir")); if (roomInfo.requiresAuth) { session.requireAuth = true; } } catch (e) { console.log('Could not check room requirements:', e); } } } // Show auth UI if required and not authenticated if (!session.authToken && (session.requireAuth || session.director)) { // If the page is in auth mode or the director is attempting to use auth, // encourage sign-in proactively. showAuthUI(); } } } // Show authentication UI function showAuthUI(options = {}) { const authContainer = document.createElement('div'); authContainer.id = 'auth-container'; authContainer.innerHTML = `

Sign in to VDO.Ninja

${options.message || 'Sign in to claim your personal stream ID and enable advanced features'}

${(!session.requireAuth && !options.requireAuth) ? '' : ''}
`; document.body.appendChild(authContainer); } // Social sign-in handler function socialSignIn(provider) { const returnUrl = encodeURIComponent(window.location.href); window.location.href = `${AUTH_SERVICE_URL}/auth/${provider}?returnUrl=${returnUrl}`; } // Skip authentication function skipAuth() { const authContainer = document.getElementById('auth-container'); if (authContainer) { authContainer.remove(); } session.authSkipped = true; } // Populate user info from auth token async function populateUserInfo() { if (!session.authToken) return; try { const response = await fetch(`${AUTH_SERVICE_URL}/api/user/info`, { headers: { 'Authorization': `Bearer ${session.authToken}` } }); if (response.ok) { const userInfo = await response.json(); session.authUser = userInfo; // Auto-populate label if not set if (!session.label && userInfo.displayName) { session.label = userInfo.displayName; if (document.getElementById("label_input")) { document.getElementById("label_input").value = session.label; } } // Auto-populate avatar if not set if (!session.avatar && userInfo.avatar) { session.avatar = userInfo.avatar; updateAvatarDisplay(); } // Store user handle session.userHandle = userInfo.userHandle; // Show user info in UI showUserInfo(userInfo); } } catch (e) { console.error("Failed to get user info:", e); } } // Show user info in UI function showUserInfo(userInfo) { const existingDisplay = document.getElementById('user-info-display'); if (existingDisplay) { existingDisplay.remove(); } const userDisplay = document.createElement('div'); userDisplay.id = 'user-info-display'; userDisplay.className = 'user-info-display'; userDisplay.innerHTML = ` ${userInfo.displayName}
${userInfo.displayName}
${userInfo.userHandle}
`; // Add to appropriate location based on current view const targetElement = document.querySelector('.header-container') || document.querySelector('.container'); if (targetElement) { targetElement.insertBefore(userDisplay, targetElement.firstChild); } } // Assign authenticated stream ID async function assignAuthStream() { if (!session.authToken || session.authStreamAssigned) return; try { const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/assign`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ roomId: session.roomid || 'lobby', deviceLabel: session.streamID || 'camera', useEncryption: false // Disabled for now until fully tested }) }); if (response.ok) { const assignment = await response.json(); // Store original stream ID session.originalStreamID = session.streamID; // Use assigned stream ID session.streamID = assignment.streamId; session.streamSecret = assignment.streamSecret; session.authStreamAssigned = true; console.log("Assigned authenticated stream:", assignment.streamId); // Update any UI showing stream ID updateStreamIDDisplay(); } } catch (e) { console.error("Failed to assign auth stream:", e); } } // Generate stream authentication signature async function generateStreamSignature() { if (!session.streamSecret) return null; const timestamp = Date.now(); const message = `${session.streamID}:${timestamp}`; const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(session.streamSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); const hexSignature = Array.from(new Uint8Array(signature)) .map(b => b.toString(16).padStart(2, '0')) .join(''); return { streamId: session.streamID, userHandle: session.userHandle, timestamp: timestamp, signature: hexSignature }; } // Validate incoming stream authentication async function validateStreamAuth(streamId, authData) { if (!session.authToken || !authData) return true; try { const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/verify`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ streamId: streamId, auth: authData }) }); if (response.ok) { const result = await response.json(); if (result.valid && result.userInfo) { // Store user info for this stream session.authStreamMapping[streamId] = result.userInfo; // Update UI if this is a director view if (session.director) { updateStreamDisplay(streamId, result.userInfo); } } return result.valid; } } catch (e) { console.error("Stream validation failed:", e); } return false; } // Resolve view handles (e.g., @johndoe) to stream IDs async function resolveViewHandles(viewList) { if (!session.authToken) return viewList; const resolved = []; for (const target of viewList) { if (target.startsWith('@')) { // User handle - resolve to current stream try { const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/user/${target}`, { headers: { 'Authorization': `Bearer ${session.authToken}` } }); if (response.ok) { const data = await response.json(); if (data.currentStreamId) { resolved.push(data.currentStreamId); // Store mapping for UI session.handleToStream[target] = data; } } } catch (e) { console.error(`Failed to resolve handle ${target}:`, e); } } else { resolved.push(target); } } return resolved; } // Check room access async function checkRoomAccess(roomIdOrAlias, isDirector = false) { console.log('Checking room access for:', roomIdOrAlias, 'with universal token:', session.universalToken); const response = await fetch(`${AUTH_SERVICE_URL}/api/room/access`, { method: 'POST', headers: { 'Authorization': session.authToken ? `Bearer ${session.authToken}` : '', 'Content-Type': 'application/json' }, body: JSON.stringify({ room: roomIdOrAlias, isDirector: isDirector, universalToken: session.universalToken || null }) }); const data = await response.json(); console.log('Room access response:', response.status, data); // Handle room not found case if (response.status === 404 && data && data.error === 'Room not found') { // In auth mode, non-existent rooms can be created by authenticated users if (session.authToken) { // Allow authenticated users to proceed - room will be created on first join return { roomId: roomIdOrAlias, alias: roomIdOrAlias, displayName: roomIdOrAlias, requiresAuth: false, hasAccess: true, isNew: true }; } else { // Require auth to create new rooms return { alias: roomIdOrAlias, displayName: roomIdOrAlias, requiresAuth: true, hasAccess: false, accessDenied: true, denialReason: 'Sign in to create or join this room' }; } } return data; } // Join room with authentication async function joinRoomWithAuth(roomIdOrAlias) { // If director is using auth mode but not signed in yet, force sign in first if (session.director && session.authMode && !session.authToken && !session.universalToken) { const roomLabel = roomIdOrAlias || 'this room'; showAuthUI({ message: `Sign in to manage "${roomLabel}"`, requireAuth: true }); return false; } // If we have a universal token, validate it first if (session.universalToken) { try { const response = await fetch(`${AUTH_SERVICE_URL}/api/room/validate-universal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: session.universalToken, roomId: roomIdOrAlias }) }); if (response.ok) { const result = await response.json(); if (result.valid) { // Universal token is valid, bypass normal auth session.roomid = roomIdOrAlias; return true; } } } catch (e) { console.error('Failed to validate universal token:', e); } } const roomInfo = await checkRoomAccess(roomIdOrAlias, session.director); if (roomInfo.requiresAuth && !session.authToken && !session.universalToken) { if (session.authSkipped) { // User already chose to skip auth, show access denied instead of auth UI showAccessDeniedUI({ ...roomInfo, denialReason: 'This room requires authentication. Please reload the page and sign in to join.', requestAccessUrl: null }); return false; } else { // First time seeing auth requirement for this room const displayLabel = roomInfo.displayName || roomInfo.alias || roomIdOrAlias || roomInfo.roomId || 'this room'; showAuthUI({ message: `Sign in to join "${displayLabel}"`, requireAuth: true }); return false; } } if (roomInfo.accessDenied) { showAccessDeniedUI(roomInfo); return false; } // Important: For auth rooms, we need to use the original alias for hashing // The auth service tracks by the real room ID, but VDO uses the alias if (roomInfo.alias && roomInfo.alias === roomIdOrAlias) { // User provided the alias, keep using it session.roomid = roomIdOrAlias; } else if (roomInfo.roomId === roomIdOrAlias) { // User provided the real room ID session.roomid = roomInfo.alias || roomIdOrAlias; } else { // Default case session.roomid = roomInfo.alias || roomInfo.roomId; } session.roomAlias = roomInfo.alias; session.realRoomId = roomInfo.roomId; return true; } // Show access denied UI function showAccessDeniedUI(roomInfo) { const modal = document.createElement('div'); modal.id = 'auth-container'; modal.innerHTML = `

Access Denied

${roomInfo.denialReason}

${roomInfo.requestAccessUrl ? `` : '' }
`; document.body.appendChild(modal); } // Request room access async function requestRoomAccess(roomId) { if (!session.authToken) { showAuthUI({ message: 'Sign in to request access' }); return; } try { const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request-access/${roomId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}` } }); if (response.ok) { alert('Access request sent! The room owner will review your request.'); document.getElementById('auth-container').remove(); } } catch (e) { console.error('Failed to request access:', e); } } // Update stream display with user info function updateStreamDisplay(streamId, userInfo) { // Update control box if it exists const controlBox = document.getElementById(`controls_${streamId}`); if (controlBox && userInfo) { const header = controlBox.querySelector('.header'); if (header && !header.querySelector('.user-auth-badge')) { const badge = document.createElement('div'); badge.className = 'user-auth-badge'; badge.innerHTML = ` ${userInfo.displayName} ${userInfo.userHandle} ${userInfo.provider} `; header.appendChild(badge); } } // Update any labels showing stream ID const labels = document.querySelectorAll(`[data-stream-id="${streamId}"]`); labels.forEach(label => { if (userInfo && !label.dataset.updated) { label.dataset.updated = 'true'; label.textContent = userInfo.displayName || userInfo.userHandle; } }); } // Update avatar display function updateAvatarDisplay() { if (session.avatar) { // Update any avatar displays in the UI const avatarElements = document.querySelectorAll('.avatar-display'); avatarElements.forEach(el => { el.src = session.avatar; }); } } // Update stream ID display function updateStreamIDDisplay() { // Update any UI elements showing the stream ID const streamIdElements = document.querySelectorAll('.stream-id-display'); streamIdElements.forEach(el => { el.textContent = session.originalStreamID || session.streamID; }); } // Resolve any stream ID (encrypted or not) through auth service async function resolveStream(streamId) { if (!session.authToken && !session.universalToken) { return { error: 'Not authenticated' }; } try { const headers = { 'Content-Type': 'application/json' }; if (session.authToken) { headers['Authorization'] = `Bearer ${session.authToken}`; } const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/resolve`, { method: 'POST', headers: headers, body: JSON.stringify({ streamId: streamId, roomId: session.roomid, universalToken: session.universalToken }) }); if (response.ok) { const data = await response.json(); return data; } else if (response.status === 403) { return { error: 'Access denied' }; } else if (response.status === 404) { return { error: 'Stream not found' }; } } catch (e) { console.error('Failed to resolve stream:', e); return { error: 'Failed to resolve stream' }; } return { error: 'Unknown error' }; } // Get encryption key for viewing a stream async function getStreamKey(streamId) { if (!session.authToken) return null; try { const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/key`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ streamId: streamId, roomId: session.roomid }) }); if (response.ok) { const data = await response.json(); return data; } } catch (e) { console.error('Failed to get stream key:', e); } return null; } // Decrypt stream ID using XOR cipher async function decryptStreamId(encryptedId, key) { // Add padding if needed const base64 = encryptedId .replace(/-/g, '+') .replace(/_/g, '/') .padEnd(encryptedId.length + (4 - encryptedId.length % 4) % 4, '='); const encrypted = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); const keyData = new TextEncoder().encode(key); const decrypted = new Uint8Array(encrypted.length); for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyData[i % keyData.length]; } return new TextDecoder().decode(decrypted); } // Heartbeat to keep stream active function startAuthHeartbeat() { if (!session.authToken || !session.streamID) return; setInterval(async () => { if (session.authToken && session.streamID && session.authStreamAssigned) { try { await fetch(`${AUTH_SERVICE_URL}/api/stream/heartbeat`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ streamId: session.streamID, roomId: session.roomid || 'lobby' }) }); } catch (e) { console.error('Heartbeat failed:', e); } } }, 30000); // Every 30 seconds } // Create a universal token for view/scene links async function createUniversalToken() { if (!session.authToken || !session.roomid) { console.error('Must be authenticated and in a room to create universal token'); return null; } try { console.log('Creating universal token for room:', session.roomid); const response = await fetch(`${AUTH_SERVICE_URL}/api/room/universal-token`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ roomId: session.roomid, description: 'View/Scene access token' }) }); if (response.ok) { const data = await response.json(); session.universalViewToken = data.token; console.log('Created universal token:', data.token); // Update all existing solo links updateAllSoloLinks(); return data.token; } else { console.error('Failed to create universal token:', response.status); } } catch (e) { console.error('Failed to create universal token:', e); } return null; } // Update all solo link displays with new token function updateAllSoloLinks() { // Update all solo link inputs and displays document.querySelectorAll('[data-sololink]').forEach(ele => { const uuid = ele.getAttribute('data--u-u-i-d'); if (uuid && session.rpcs[uuid]) { const soloLink = soloLinkGenerator(session.rpcs[uuid].streamID, false); if (ele.tagName === 'INPUT') { ele.value = soloLink; } else if (ele.tagName === 'A') { ele.href = soloLink; ele.innerText = soloLink; } } }); // Update director's own solo link if present const directorLink = document.querySelector('#grabDirectorSoloLink'); if (directorLink && session.streamID) { const soloLink = soloLinkGenerator(session.streamID, true); directorLink.dataset.raw = soloLink; directorLink.href = soloLink; directorLink.innerText = soloLink; } // Update solo links in control boxes document.querySelectorAll('.soloLink').forEach(ele => { if (ele.getAttribute('value')) { const baseUrl = ele.getAttribute('value'); // Extract stream ID from the base URL const match = baseUrl.match(/[?&]view=([^&]+)/); if (match && match[1]) { const streamId = match[1]; const soloLink = soloLinkGenerator(streamId, false); ele.href = soloLink; ele.innerHTML = soloLink; } } }); } // Update room settings (access mode, allowlist) async function updateRoomSettings(roomId, settings) { if (!session.authToken) { console.error('Must be authenticated to update room settings'); return null; } try { const response = await fetch(`${AUTH_SERVICE_URL}/api/room/settings/${roomId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${session.authToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); if (response.ok) { const data = await response.json(); console.log('Room settings updated'); return data; } else { console.error('Failed to update room settings:', response.status); } } catch (e) { console.error('Failed to update room settings:', e); } return null; } // Get pending access requests for a room async function getRoomAccessRequests(roomId) { if (!session.authToken) { console.error('Must be authenticated to get access requests'); return []; } try { const response = await fetch(`${AUTH_SERVICE_URL}/api/room/requests/${roomId}`, { headers: { 'Authorization': `Bearer ${session.authToken}` } }); if (response.ok) { return await response.json(); } } catch (e) { console.error('Failed to get access requests:', e); } return []; } // Approve or deny an access request async function handleAccessRequest(roomId, userId, action) { if (!session.authToken) { console.error('Must be authenticated to handle access requests'); return false; } try { const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request/${roomId}/${userId}/${action}`, { method: 'POST', headers: { 'Authorization': `Bearer ${session.authToken}` } }); return response.ok; } catch (e) { console.error('Failed to handle access request:', e); } return false; } // Export functions for use in main VDO.Ninja code window.vdoAuth = { init: initAuthentication, assignStream: assignAuthStream, generateSignature: generateStreamSignature, validateStream: validateStreamAuth, resolveHandles: resolveViewHandles, checkRoomAccess: checkRoomAccess, joinRoom: joinRoomWithAuth, startHeartbeat: startAuthHeartbeat, getStreamKey: getStreamKey, decryptStreamId: decryptStreamId, resolveStream: resolveStream, createUniversalToken: createUniversalToken, updateRoomSettings: updateRoomSettings, getRoomAccessRequests: getRoomAccessRequests, handleAccessRequest: handleAccessRequest };