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:
parent
d7fc2cc8db
commit
f174709086
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue