feat: add EncryptID auth to sync server and gate room creation

Verify JWT tokens on WebSocket connections via query param. Check room
visibility before allowing access. Block writes from read-only connections.
Add room config endpoint. Require auth for creating new rooms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 11:54:20 -07:00
parent d7fc2cc8db
commit f174709086
6 changed files with 204 additions and 15 deletions

View File

@ -33,7 +33,7 @@ export default function RoomPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const slug = params.slug as string; const slug = params.slug as string;
const { did: encryptIdDid } = useAuthStore(); const { did: encryptIdDid, token: authToken } = useAuthStore();
const [showShare, setShowShare] = useState(false); const [showShare, setShowShare] = useState(false);
const [showParticipants, setShowParticipants] = useState(true); const [showParticipants, setShowParticipants] = useState(true);
@ -95,6 +95,7 @@ export default function RoomPage() {
userName: currentUser?.name || '', userName: currentUser?.name || '',
userEmoji: currentUser?.emoji || '👤', userEmoji: currentUser?.emoji || '👤',
encryptIdDid, encryptIdDid,
authToken,
}); });
// Use refs to avoid stale closures in callbacks // Use refs to avoid stale closures in callbacks

View File

@ -63,6 +63,12 @@ export default function HomePage() {
const handleCreateRoom = async () => { const handleCreateRoom = async () => {
if (!name.trim()) return; if (!name.trim()) return;
// Require EncryptID auth to create rooms
if (!isAuthenticated) {
alert('Please sign in with EncryptID to create a room.');
return;
}
const slug = roomName.trim() const slug = roomName.trim()
? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20) ? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20)
: generateSlug(); : generateSlug();
@ -169,9 +175,10 @@ export default function HomePage() {
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="btn-primary w-full text-lg py-3" className="btn-primary w-full text-lg py-3"
disabled={!name.trim()} disabled={!name.trim() || !isAuthenticated}
title={!isAuthenticated ? 'Sign in with EncryptID to create rooms' : ''}
> >
Create New Map {isAuthenticated ? 'Create New Map' : 'Sign in to Create Map'}
</button> </button>
<div className="text-center text-white/40 text-sm">or</div> <div className="text-center text-white/40 text-sm">or</div>

View File

@ -99,6 +99,8 @@ interface UseRoomOptions {
userEmoji: string; userEmoji: string;
/** EncryptID DID for persistent cross-session identity (optional) */ /** EncryptID DID for persistent cross-session identity (optional) */
encryptIdDid?: string | null; encryptIdDid?: string | null;
/** EncryptID JWT token for authenticated sync server access (optional) */
authToken?: string | null;
} }
interface UseRoomReturn { interface UseRoomReturn {
@ -119,7 +121,7 @@ interface UseRoomReturn {
setLocationRequestCallback: (callback: () => void) => void; setLocationRequestCallback: (callback: () => void) => void;
} }
export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOptions): UseRoomReturn { export function useRoom({ slug, userName, userEmoji, encryptIdDid, authToken }: UseRoomOptions): UseRoomReturn {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -182,12 +184,14 @@ export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOpti
participantIdRef.current = participantId; participantIdRef.current = participantId;
const color = COLORS[Math.floor(Math.random() * COLORS.length)]; const color = COLORS[Math.floor(Math.random() * COLORS.length)];
// Create sync instance // Create sync instance (pass auth token for server-side access control)
const sync = new RoomSync( const sync = new RoomSync(
slug, slug,
participantId, participantId,
handleStateChange, handleStateChange,
handleConnectionChange handleConnectionChange,
undefined,
authToken
); );
syncRef.current = sync; syncRef.current = sync;

View File

@ -106,19 +106,22 @@ export class RoomSync {
private onLocationRequest: LocationRequestCallback | null = null; private onLocationRequest: LocationRequestCallback | null = null;
private participantId: string; private participantId: string;
private currentParticipant: ParticipantState | null = null; private currentParticipant: ParticipantState | null = null;
private authToken: string | null = null;
constructor( constructor(
slug: string, slug: string,
participantId: string, participantId: string,
onStateChange: SyncCallback, onStateChange: SyncCallback,
onConnectionChange: ConnectionCallback, onConnectionChange: ConnectionCallback,
onLocationRequest?: LocationRequestCallback onLocationRequest?: LocationRequestCallback,
authToken?: string | null
) { ) {
this.slug = slug; this.slug = slug;
this.participantId = participantId; this.participantId = participantId;
this.onStateChange = onStateChange; this.onStateChange = onStateChange;
this.onConnectionChange = onConnectionChange; this.onConnectionChange = onConnectionChange;
this.onLocationRequest = onLocationRequest || null; this.onLocationRequest = onLocationRequest || null;
this.authToken = authToken || null;
// Initialize or load state // Initialize or load state
this.state = this.loadState() || this.createInitialState(); this.state = this.loadState() || this.createInitialState();
@ -215,7 +218,10 @@ export class RoomSync {
} }
try { try {
this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`); const wsUrl = this.authToken
? `${syncUrl}/room/${this.slug}?token=${encodeURIComponent(this.authToken)}`
: `${syncUrl}/room/${this.slug}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('Connected to sync server'); console.log('Connected to sync server');

View File

@ -3,6 +3,7 @@ import { createServer } from 'http';
import { parse } from 'url'; import { parse } from 'url';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import webpush from 'web-push'; import webpush from 'web-push';
import { verifyToken, extractTokenFromURL } from './verify-token.js';
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
@ -25,7 +26,7 @@ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
// Room state storage: Map<roomSlug, RoomState> // Room state storage: Map<roomSlug, RoomState>
const rooms = new Map(); const rooms = new Map();
// Client tracking: Map<WebSocket, { roomSlug, participantId }> // Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
const clients = new Map(); const clients = new Map();
// Push subscriptions: Map<roomSlug, Set<subscription>> // Push subscriptions: Map<roomSlug, Set<subscription>>
@ -40,7 +41,9 @@ function getRoomState(slug) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
participants: {}, participants: {},
waypoints: [], waypoints: [],
lastActivity: Date.now() lastActivity: Date.now(),
visibility: 'public', // public | public_read | authenticated | members_only
ownerDID: null,
}); });
} }
const room = rooms.get(slug); const room = rooms.get(slug);
@ -48,6 +51,41 @@ function getRoomState(slug) {
return room; return room;
} }
/**
* Evaluate whether a connection should be allowed based on room visibility.
* Returns { allowed, readOnly, reason }
*/
function evaluateRoomAccess(room, claims, isRead = false) {
const { visibility, ownerDID } = room;
const isOwner = !!(claims && ownerDID && (claims.sub === ownerDID || claims.did === ownerDID));
if (visibility === 'public') {
return { allowed: true, readOnly: false, isOwner };
}
if (visibility === 'public_read') {
// Everyone can connect, but writes require auth
return { allowed: true, readOnly: !claims, isOwner };
}
if (visibility === 'authenticated') {
if (!claims) {
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
}
return { allowed: true, readOnly: false, isOwner };
}
if (visibility === 'members_only') {
if (!claims) {
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
}
// Further membership checks could be added here
return { allowed: true, readOnly: false, isOwner };
}
return { allowed: false, readOnly: false, isOwner: false, reason: 'Unknown visibility' };
}
function cleanupStaleParticipants(room) { function cleanupStaleParticipants(room) {
const now = Date.now(); const now = Date.now();
const staleIds = []; const staleIds = [];
@ -130,6 +168,13 @@ function handleMessage(ws, data) {
const room = getRoomState(clientInfo.roomSlug); const room = getRoomState(clientInfo.roomSlug);
// Block write operations from readOnly connections
const writeOps = ['join', 'leave', 'location', 'status', 'waypoint_add', 'waypoint_remove'];
if (clientInfo.readOnly && writeOps.includes(message.type)) {
ws.send(JSON.stringify({ type: 'error', error: 'Read-only access — authenticate to interact' }));
return;
}
switch (message.type) { switch (message.type) {
case 'join': { case 'join': {
const participant = { const participant = {
@ -358,7 +403,7 @@ async function parseJsonBody(req) {
function addCorsHeaders(res) { function addCorsHeaders(res) {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
} }
// Create HTTP server for health checks and push subscription endpoints // Create HTTP server for health checks and push subscription endpoints
@ -469,6 +514,53 @@ const server = createServer(async (req, res) => {
res.writeHead(500, { 'Content-Type': 'application/json' }); res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test' })); res.end(JSON.stringify({ error: 'Failed to send test' }));
} }
} else if (pathname?.startsWith('/room/') && pathname.endsWith('/config') && req.method === 'POST') {
// Set room visibility and config (requires auth + ownership)
try {
const roomSlug = pathname.replace('/room/', '').replace('/config', '');
const body = await parseJsonBody(req);
const authHeader = req.headers['authorization'] || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
const claims = token ? await verifyToken(token) : null;
if (!claims) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Authentication required' }));
return;
}
const room = getRoomState(roomSlug);
// Only the owner (or first authenticated user) can change room config
if (room.ownerDID && room.ownerDID !== claims.sub && room.ownerDID !== claims.did) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Only the room owner can change settings' }));
return;
}
// Set owner if not set
if (!room.ownerDID) {
room.ownerDID = claims.sub || claims.did;
}
const validVisibilities = ['public', 'public_read', 'authenticated', 'members_only'];
if (body.visibility && validVisibilities.includes(body.visibility)) {
room.visibility = body.visibility;
}
if (body.name) {
room.name = body.name;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
room: { slug: room.slug, visibility: room.visibility, ownerDID: room.ownerDID, name: room.name }
}));
} catch (error) {
console.error('Room config error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to update room config' }));
}
} else if (pathname === '/push/request-location' && req.method === 'POST') { } else if (pathname === '/push/request-location' && req.method === 'POST') {
// Manually trigger location request for a room or specific participant // Manually trigger location request for a room or specific participant
// Uses WebSocket for online clients, push for offline/background // Uses WebSocket for online clients, push for offline/background
@ -577,7 +669,7 @@ const server = createServer(async (req, res) => {
// Create WebSocket server // Create WebSocket server
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => { wss.on('connection', async (ws, req) => {
const { pathname } = parse(req.url); const { pathname } = parse(req.url);
// Extract room slug from path: /room/{slug} // Extract room slug from path: /room/{slug}
@ -589,10 +681,37 @@ wss.on('connection', (ws, req) => {
} }
const roomSlug = decodeURIComponent(match[1]); const roomSlug = decodeURIComponent(match[1]);
console.log(`New connection to room: ${roomSlug}`);
// Register client // Extract and verify auth token from query string
clients.set(ws, { roomSlug, participantId: null }); const token = extractTokenFromURL(req.url);
let claims = null;
if (token) {
claims = await verifyToken(token);
if (claims) {
console.log(`[${roomSlug}] Authenticated connection: ${claims.username || claims.sub}`);
}
}
// Check room access based on visibility
const room = getRoomState(roomSlug);
const access = evaluateRoomAccess(room, claims);
if (!access.allowed) {
console.log(`[${roomSlug}] Connection rejected: ${access.reason}`);
ws.close(4001, access.reason || 'Access denied');
return;
}
console.log(`New connection to room: ${roomSlug}${access.readOnly ? ' (read-only)' : ''}`);
// Register client with auth info
clients.set(ws, { roomSlug, participantId: null, claims, readOnly: access.readOnly });
// If authenticated and room has no owner, set this user as owner
if (claims && !room.ownerDID && Object.keys(room.participants).length === 0) {
room.ownerDID = claims.sub || claims.did;
console.log(`[${roomSlug}] Room owner set to: ${room.ownerDID}`);
}
// Set up handlers // Set up handlers
ws.on('message', (data) => handleMessage(ws, data.toString())); ws.on('message', (data) => handleMessage(ws, data.toString()));

View File

@ -0,0 +1,52 @@
/**
* Lightweight EncryptID JWT verification for the sync server.
*
* Verifies tokens by calling the EncryptID server's /api/session/verify endpoint.
* No dependencies required uses Node.js built-in fetch.
*/
const ENCRYPTID_SERVER_URL = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
/**
* Verify an EncryptID JWT token by calling the EncryptID server.
* @param {string} token - The JWT token to verify
* @returns {Promise<object|null>} The claims if valid, null otherwise
*/
export async function verifyToken(token) {
if (!token) return null;
try {
const res = await fetch(`${ENCRYPTID_SERVER_URL.replace(/\/$/, '')}/api/session/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
if (data.valid) {
return data.claims || {};
}
return null;
} catch (error) {
console.error('Token verification failed:', error.message);
return null;
}
}
/**
* Extract token from a URL query string.
* @param {string} url - The full URL or path with query string
* @returns {string|null} The token or null
*/
export function extractTokenFromURL(url) {
try {
// parse handles both full URLs and just paths with query strings
const searchParams = new URL(url, 'http://localhost').searchParams;
return searchParams.get('token') || null;
} catch {
return null;
}
}