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 router = useRouter();
|
||||
const slug = params.slug as string;
|
||||
const { did: encryptIdDid } = useAuthStore();
|
||||
const { did: encryptIdDid, token: authToken } = useAuthStore();
|
||||
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(true);
|
||||
|
|
@ -95,6 +95,7 @@ export default function RoomPage() {
|
|||
userName: currentUser?.name || '',
|
||||
userEmoji: currentUser?.emoji || '👤',
|
||||
encryptIdDid,
|
||||
authToken,
|
||||
});
|
||||
|
||||
// Use refs to avoid stale closures in callbacks
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export default function HomePage() {
|
|||
const handleCreateRoom = async () => {
|
||||
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()
|
||||
? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20)
|
||||
: generateSlug();
|
||||
|
|
@ -169,9 +175,10 @@ export default function HomePage() {
|
|||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
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>
|
||||
|
||||
<div className="text-center text-white/40 text-sm">or</div>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ interface UseRoomOptions {
|
|||
userEmoji: string;
|
||||
/** EncryptID DID for persistent cross-session identity (optional) */
|
||||
encryptIdDid?: string | null;
|
||||
/** EncryptID JWT token for authenticated sync server access (optional) */
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
interface UseRoomReturn {
|
||||
|
|
@ -119,7 +121,7 @@ interface UseRoomReturn {
|
|||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -182,12 +184,14 @@ export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOpti
|
|||
participantIdRef.current = participantId;
|
||||
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(
|
||||
slug,
|
||||
participantId,
|
||||
handleStateChange,
|
||||
handleConnectionChange
|
||||
handleConnectionChange,
|
||||
undefined,
|
||||
authToken
|
||||
);
|
||||
syncRef.current = sync;
|
||||
|
||||
|
|
|
|||
|
|
@ -106,19 +106,22 @@ export class RoomSync {
|
|||
private onLocationRequest: LocationRequestCallback | null = null;
|
||||
private participantId: string;
|
||||
private currentParticipant: ParticipantState | null = null;
|
||||
private authToken: string | null = null;
|
||||
|
||||
constructor(
|
||||
slug: string,
|
||||
participantId: string,
|
||||
onStateChange: SyncCallback,
|
||||
onConnectionChange: ConnectionCallback,
|
||||
onLocationRequest?: LocationRequestCallback
|
||||
onLocationRequest?: LocationRequestCallback,
|
||||
authToken?: string | null
|
||||
) {
|
||||
this.slug = slug;
|
||||
this.participantId = participantId;
|
||||
this.onStateChange = onStateChange;
|
||||
this.onConnectionChange = onConnectionChange;
|
||||
this.onLocationRequest = onLocationRequest || null;
|
||||
this.authToken = authToken || null;
|
||||
|
||||
// Initialize or load state
|
||||
this.state = this.loadState() || this.createInitialState();
|
||||
|
|
@ -215,7 +218,10 @@ export class RoomSync {
|
|||
}
|
||||
|
||||
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 = () => {
|
||||
console.log('Connected to sync server');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createServer } from 'http';
|
|||
import { parse } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import webpush from 'web-push';
|
||||
import { verifyToken, extractTokenFromURL } from './verify-token.js';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
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>
|
||||
const rooms = new Map();
|
||||
|
||||
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
|
||||
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
|
||||
const clients = new Map();
|
||||
|
||||
// Push subscriptions: Map<roomSlug, Set<subscription>>
|
||||
|
|
@ -40,7 +41,9 @@ function getRoomState(slug) {
|
|||
createdAt: new Date().toISOString(),
|
||||
participants: {},
|
||||
waypoints: [],
|
||||
lastActivity: Date.now()
|
||||
lastActivity: Date.now(),
|
||||
visibility: 'public', // public | public_read | authenticated | members_only
|
||||
ownerDID: null,
|
||||
});
|
||||
}
|
||||
const room = rooms.get(slug);
|
||||
|
|
@ -48,6 +51,41 @@ function getRoomState(slug) {
|
|||
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) {
|
||||
const now = Date.now();
|
||||
const staleIds = [];
|
||||
|
|
@ -130,6 +168,13 @@ function handleMessage(ws, data) {
|
|||
|
||||
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) {
|
||||
case 'join': {
|
||||
const participant = {
|
||||
|
|
@ -358,7 +403,7 @@ async function parseJsonBody(req) {
|
|||
function addCorsHeaders(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
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
|
||||
|
|
@ -469,6 +514,53 @@ const server = createServer(async (req, res) => {
|
|||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
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') {
|
||||
// Manually trigger location request for a room or specific participant
|
||||
// Uses WebSocket for online clients, push for offline/background
|
||||
|
|
@ -577,7 +669,7 @@ const server = createServer(async (req, res) => {
|
|||
// Create WebSocket server
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
wss.on('connection', async (ws, req) => {
|
||||
const { pathname } = parse(req.url);
|
||||
|
||||
// Extract room slug from path: /room/{slug}
|
||||
|
|
@ -589,10 +681,37 @@ wss.on('connection', (ws, req) => {
|
|||
}
|
||||
|
||||
const roomSlug = decodeURIComponent(match[1]);
|
||||
console.log(`New connection to room: ${roomSlug}`);
|
||||
|
||||
// Register client
|
||||
clients.set(ws, { roomSlug, participantId: null });
|
||||
// Extract and verify auth token from query string
|
||||
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
|
||||
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