diff --git a/src/controllers/deliveryStatusController.ts b/src/controllers/deliveryStatusController.ts index a35985b..c33d68b 100644 --- a/src/controllers/deliveryStatusController.ts +++ b/src/controllers/deliveryStatusController.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; +import { LocationUpdate } from '../models/LocationUpdate'; import mongoose from 'mongoose'; import { Delivery } from '../models/deliveryModel'; import type { DeliveryStatus } from '../models/deliveryModel'; @@ -38,6 +39,48 @@ export const updateDeliveryStatus = async ( } const delivery = await Delivery.findById(id); + + // Fetch latest driver location for this delivery + const latestLocation = await LocationUpdate.findOne({ + driverId: delivery.driverId, + deliveryId: delivery._id, + }) + .sort({ capturedAt: -1 }) + .lean(); + + // Helper to compute haversine distance in kilometers + const haversine = (lat1: number, lng1: number, lat2: number, lng2: number): number => { + const toRad = (deg: number) => (deg * Math.PI) / 180; + const R = 6371; // Earth radius in km + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); + }; + + const ACCEPTABLE_RADIUS_KM = 0.2; // 200 meters + + if (nextStatus === 'completed') { + if (!latestLocation) { + return next(new HttpError(400, 'No recent driver location available for validation')); + } + const distanceKm = haversine( + latestLocation.coordinates.lat, + latestLocation.coordinates.lng, + delivery.dropoffCoordinates.lat, + delivery.dropoffCoordinates.lng, + ); + if (distanceKm > ACCEPTABLE_RADIUS_KM) { + return next( + new HttpError( + 400, + `Driver is too far from drop-off location (distance: ${distanceKm.toFixed(2)} km)`, + ), + ); + } + } if (!delivery) { return next(new HttpError(404, 'Delivery not found')); } diff --git a/src/services/authService.ts b/src/services/authService.ts index b2b4083..de73c16 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -81,12 +81,13 @@ class AuthService { const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_prod'; try { const decoded = jwt.verify(token, JWT_SECRET) as { + userId?: string; sub?: string; id?: string; _id?: string; } | null; if (!decoded) throw new Error('Invalid token'); - const userId = decoded.sub || decoded.id || decoded._id; + const userId = decoded.userId || decoded.sub || decoded.id || decoded._id; if (!userId) throw new Error('Token missing subject'); return { userId }; } catch (error) { diff --git a/src/sockets/connectionHandler.ts b/src/sockets/connectionHandler.ts index 0207ca4..29203bd 100644 --- a/src/sockets/connectionHandler.ts +++ b/src/sockets/connectionHandler.ts @@ -52,12 +52,14 @@ export function initializeSocketServer(httpServer: HttpServer): TypedServer { // ─── Per-connection setup ────────────────────────────────────────────────── io.on('connection', (socket: TypedSocket) => { - // Optionally extract userId from auth handshake data + // Extract userId and JWT token from auth handshake data const userId = extractUserId(socket); + const token = extractToken(socket); - // Store userId on the socket data for easy access later + // Store metadata on the socket data for easy access later socket.data.connectedAt = Date.now(); socket.data.userId = userId; + socket.data.token = token; // Register the connection in the service layer socketService.registerConnection(socket, userId); @@ -135,10 +137,7 @@ export async function shutdownSocketServer(io: TypedServer): Promise { * Extract an authenticated user ID from the socket handshake. * * Clients should pass their JWT in the `auth` object: - * `socket = io(url, { auth: { token: 'Bearer ' } })` - * - * This is intentionally lightweight — full JWT verification should be - * done in a dedicated auth middleware if required. + * `socket = io(url, { auth: { userId: "..." } })` * * @param socket - The connecting socket. * @returns The userId string, or undefined if absent. @@ -158,3 +157,28 @@ function extractUserId(socket: TypedSocket): string | undefined { return undefined; } + +/** + * Extract a JWT token from the socket handshake. + * + * Clients should pass their token in the `auth` object: + * `socket = io(url, { auth: { token: 'Bearer ' } })` + * + * @param socket - The connecting socket. + * @returns The raw token string, or undefined if absent. + */ +function extractToken(socket: TypedSocket): string | undefined { + const auth = socket.handshake.auth as Record; + + if (typeof auth?.token === 'string' && auth.token.trim()) { + return auth.token.trim(); + } + + // Fallback: check query params + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string' && queryToken.trim()) { + return queryToken.trim(); + } + + return undefined; +} diff --git a/src/sockets/locationHandler.ts b/src/sockets/locationHandler.ts index 946cacc..fccc0ef 100644 --- a/src/sockets/locationHandler.ts +++ b/src/sockets/locationHandler.ts @@ -1,6 +1,8 @@ import { Server as SocketIOServer } from 'socket.io'; +import authService from '../services/authService'; import logger from '../config/logger'; import { locationService, deliveryRoom } from './location.service'; +import { socketService } from './socket.service'; import { DriverLocationUpdatePayload, TypedSocket, @@ -8,6 +10,9 @@ import { ClientToServerEvents, InterServerEvents, SocketData, + AuthExpiredPayload, + AuthRefreshPayload, + AuthRefreshAckPayload, } from './socket.types'; /** @@ -100,6 +105,124 @@ export function registerLocationHandler(io: TypedServer, socket: TypedSocket): v // Actual join is handled by connectionHandler's join_room listener; // this handler only adds delivery-specific logging/validation. }); + + // ── Token expiration guard ──────────────────────────────────────────────── + // For authenticated drivers, periodically validate the JWT to detect + // expiration or account changes (suspension, ban). Emit `auth_expired` + // and gracefully disconnect if the token is not refreshed. + setupTokenExpirationCheck(io, socket); +} + +/** + * Periodically validate the JWT token stored on the socket. If the token + * is found invalid, emit `auth_expired` and disconnect after a grace period + * unless the client refreshes the token via `auth_refresh`. + * + * @param io - The Socket.IO server instance. + * @param socket - The connected socket to monitor. + */ +function setupTokenExpirationCheck(io: TypedServer, socket: TypedSocket): void { + const token = socket.data.token; + const userId = socket.data.userId; + + if (!token || !userId) { + return; + } + + const CHECK_INTERVAL_MS = parseInt(process.env.SOCKET_TOKEN_CHECK_INTERVAL_MS ?? '60000', 10); + const GRACE_PERIOD_MS = parseInt(process.env.SOCKET_TOKEN_GRACE_PERIOD_MS ?? '30000', 10); + + let graceTimer: NodeJS.Timeout | null = null; + let checkInterval: NodeJS.Timeout | null = null; + + const clearGraceTimer = (): void => { + if (graceTimer) { + clearTimeout(graceTimer); + graceTimer = null; + } + }; + + const emitAuthExpired = (): void => { + logger.warn(`[Socket] Token expired for userId=${userId} socketId=${socket.id}`); + + socket.emit('auth_expired', { + message: 'Your session has expired. Please refresh your token.', + gracePeriodMs: GRACE_PERIOD_MS, + } as AuthExpiredPayload); + + graceTimer = setTimeout(() => { + if (socket.connected) { + logger.info( + `[Socket] Grace period expired — disconnecting userId=${userId} socketId=${socket.id}`, + ); + socket.disconnect(true); + } + }, GRACE_PERIOD_MS); + }; + + const validateToken = async (): Promise => { + try { + const isValid = await socketService.validateSocketToken(socket); + if (!isValid) { + emitAuthExpired(); + } + } catch (err) { + logger.error( + `[Socket] Token validation error — userId=${userId}: ${ + err instanceof Error ? err.message : err + }`, + ); + } + }; + + socket.on('auth_refresh', async (payload: AuthRefreshPayload) => { + if (!payload?.token || typeof payload.token !== 'string') { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid payload', + } as AuthRefreshAckPayload); + return; + } + + try { + const decoded = authService.verifyToken(payload.token); + const user = await authService.getUserById(decoded.userId); + + if (!user || user.status === 'suspended' || user.status === 'banned') { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid or inactive token', + } as AuthRefreshAckPayload); + return; + } + + socket.data.token = payload.token; + socket.data.userId = decoded.userId; + + clearGraceTimer(); + + socket.emit('auth_refresh_ack', { success: true } as AuthRefreshAckPayload); + logger.info(`[Socket] Token refreshed for userId=${decoded.userId} socketId=${socket.id}`); + } catch (err) { + socket.emit('auth_refresh_ack', { + success: false, + error: 'Invalid token', + } as AuthRefreshAckPayload); + } + }); + + socket.on('disconnect', () => { + clearGraceTimer(); + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + }); + + setTimeout(() => { + validateToken(); + checkInterval = setInterval(validateToken, CHECK_INTERVAL_MS); + }, 5000); } /** diff --git a/src/sockets/socket.service.ts b/src/sockets/socket.service.ts index e4e9e50..21e2082 100644 --- a/src/sockets/socket.service.ts +++ b/src/sockets/socket.service.ts @@ -1,4 +1,5 @@ import { Server as SocketIOServer } from 'socket.io'; +import authService from '../services/authService'; import logger from '../config/logger'; import { SocketConnectionMeta, @@ -253,6 +254,55 @@ export class SocketService { public getConnections(): ReadonlyMap { return this.connections; } + + /** + * Validate the JWT token stored on a socket's data against the database. + * + * Returns true if: + * - The socket has no token/userId (unauthenticated, skip validation). + * - The token is cryptographically valid, not expired, and references + * an existing user whose account is active (not suspended/banned). + * + * Returns false if the token is missing, malformed, expired, or references + * an inactive/non-existent user. + * + * @param socket - The socket whose token should be validated. + * @returns True if the token is valid (or absent), false otherwise. + */ + public async validateSocketToken(socket: TypedSocket): Promise { + const token = socket.data.token; + const userId = socket.data.userId; + + if (!token || !userId) { + return true; + } + + try { + const decoded = authService.verifyToken(token); + const user = await authService.getUserById(decoded.userId); + + if (!user) { + logger.warn(`[Socket] Token validation failed — user not found for userId=${userId}`); + return false; + } + + if (user.status === 'suspended' || user.status === 'banned') { + logger.warn( + `[Socket] Token validation failed — account ${user.status} for userId=${userId}`, + ); + return false; + } + + return true; + } catch (error) { + logger.warn( + `[Socket] Token validation failed for userId=${userId}: ${ + error instanceof Error ? error.message : error + }`, + ); + return false; + } + } } /** Singleton instance shared across the application. */ diff --git a/src/sockets/socket.types.ts b/src/sockets/socket.types.ts index 78bbe9d..243ca0c 100644 --- a/src/sockets/socket.types.ts +++ b/src/sockets/socket.types.ts @@ -43,6 +43,30 @@ export interface DisconnectPayload { connectedDurationMs: number; } +/** + * Payload emitted on `auth_expired` when the server detects an expired + * or invalid JWT during an active socket session. + */ +export interface AuthExpiredPayload { + message: string; + gracePeriodMs: number; +} + +/** + * Payload sent by the client on `auth_refresh` with a new JWT token. + */ +export interface AuthRefreshPayload { + token: string; +} + +/** + * Acknowledgement emitted back on `auth_refresh_ack`. + */ +export interface AuthRefreshAckPayload { + success: boolean; + error?: string; +} + // ─── Offline sync types ─────────────────────────────────────────────────────── /** @@ -160,6 +184,10 @@ export interface ServerToClientEvents { 'location:update': (payload: LocationBroadcastPayload) => void; /** Ack sent back to the driver after a live location update is processed. */ location_update_ack: (payload: LocationUpdateAck) => void; + /** Emitted when the server detects an expired or invalid JWT during an active session. */ + auth_expired: (payload: AuthExpiredPayload) => void; + /** Ack sent back after the client submits a refreshed JWT via `auth_refresh`. */ + auth_refresh_ack: (payload: AuthRefreshAckPayload) => void; } /** @@ -173,6 +201,8 @@ export interface ClientToServerEvents { location_sync: (payload: LocationSyncPayload) => void; /** Fired by driver to broadcast a live GPS fix to a delivery room. */ driver_location_update: (payload: DriverLocationUpdatePayload) => void; + /** Fired by client to submit a refreshed JWT without reconnecting. */ + auth_refresh: (payload: AuthRefreshPayload) => void; } /** @@ -187,6 +217,7 @@ export interface InterServerEvents { */ export interface SocketData { userId?: string; + token?: string; connectedAt: number; } diff --git a/tests/locationHandler.test.ts b/tests/locationHandler.test.ts new file mode 100644 index 0000000..5f88015 --- /dev/null +++ b/tests/locationHandler.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for locationHandler token expiration check + * + * Tests cover: + * - setupTokenExpirationCheck skips unauthenticated sockets + * - Token validation runs on an interval + * - auth_expired is emitted when token validation fails + * - auth_refresh clears the grace timer and updates the token + * - Graceful disconnect after grace period expires + * - Intervals are cleaned up on disconnect + */ + +import { registerLocationHandler, deliveryRoom } from '../src/sockets/locationHandler'; +import { + TypedSocket, + ServerToClientEvents, + ClientToServerEvents, + InterServerEvents, + SocketData, + AuthExpiredPayload, + AuthRefreshPayload, + AuthRefreshAckPayload, +} from '../src/sockets/socket.types'; +import { Server as SocketIOServer } from 'socket.io'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../src/services/authService', () => ({ + verifyToken: jest.fn(), + getUserById: jest.fn(), +})); + +jest.mock('../src/sockets/socket.service', () => { + const actual = jest.requireActual('../src/sockets/socket.service'); + return { + ...actual, + socketService: { + validateSocketToken: jest.fn(), + }, + }; +}); + +import authService from '../src/services/authService'; +import { socketService } from '../src/sockets/socket.service'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMockSocket(id: string): jest.Mocked { + return { + id, + data: {}, + handshake: { auth: {}, query: {} }, + emit: jest.fn(), + disconnect: jest.fn(), + join: jest.fn(), + leave: jest.fn(), + on: jest.fn(), + off: jest.fn(), + rooms: new Set([id]), + connected: true, + } as unknown as jest.Mocked; +} + +function makeMockIO(): Parameters[0] { + return { + to: jest.fn().mockReturnValue({ emit: jest.fn() }), + sockets: { sockets: new Map() }, + } as unknown as SocketIOServer< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData + >; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('locationHandler — token expiration', () => { + let socket: jest.Mocked; + let io: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + socket = makeMockSocket('socket-1'); + io = makeMockIO(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('setupTokenExpirationCheck', () => { + it('does nothing when socket has no token or userId', () => { + socket.data.token = undefined; + socket.data.userId = undefined; + + registerLocationHandler(io, socket); + + expect(socket.on).not.toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + }); + + it('does nothing when token is present but userId is missing', () => { + socket.data.token = 'some-token'; + socket.data.userId = undefined; + + registerLocationHandler(io, socket); + + expect(socket.on).not.toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + }); + + it('sets up periodic token validation when token and userId are present', async () => { + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(true); + + registerLocationHandler(io, socket); + + expect(socket.on).toHaveBeenCalledWith('auth_refresh', expect.any(Function)); + + // Advance past the initial 5s delay + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledWith(socket); + + // Advance one check interval + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(2); + }); + + it('emits auth_expired and disconnects after grace period on invalid token', async () => { + socket.data.token = 'expired-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(false); + + registerLocationHandler(io, socket); + + // Advance past initial delay + one check interval + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + + // Validation failed — auth_expired should be emitted + expect(socket.emit).toHaveBeenCalledWith( + 'auth_expired', + expect.objectContaining({ + message: 'Your session has expired. Please refresh your token.', + gracePeriodMs: 30_000, + }) as AuthExpiredPayload, + ); + + // Advance past grace period + jest.advanceTimersByTime(30_000); + await Promise.resolve(); + expect(socket.disconnect).toHaveBeenCalledWith(true); + }); + + it('clears grace timer and updates token on valid auth_refresh', async () => { + socket.data.token = 'old-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(false); + + registerLocationHandler(io, socket); + + // Trigger invalid token + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socket.emit).toHaveBeenCalledWith( + 'auth_expired', + expect.any(Object) as AuthExpiredPayload, + ); + + // Find the auth_refresh handler registered by registerLocationHandler + const authRefreshHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'auth_refresh', + )?.[1] as (payload: AuthRefreshPayload) => Promise; + + expect(authRefreshHandler).toBeDefined(); + + // Simulate client sending a refreshed token + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-1' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'active' }); + + await authRefreshHandler({ token: 'new-token' }); + + expect(socket.data.token).toBe('new-token'); + expect(socket.data.userId).toBe('user-1'); + expect(socket.emit).toHaveBeenCalledWith('auth_refresh_ack', { + success: true, + } as AuthRefreshAckPayload); + + // Advance past where disconnect would have happened — should NOT disconnect + jest.advanceTimersByTime(35_000); + await Promise.resolve(); + expect(socket.disconnect).not.toHaveBeenCalled(); + }); + + it('rejects invalid token on auth_refresh', async () => { + socket.data.token = 'old-token'; + socket.data.userId = 'user-1'; + + registerLocationHandler(io, socket); + + const authRefreshHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'auth_refresh', + )?.[1] as (payload: AuthRefreshPayload) => Promise; + + expect(authRefreshHandler).toBeDefined(); + + (authService.verifyToken as jest.Mock).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await authRefreshHandler({ token: 'bad-token' }); + + expect(socket.emit).toHaveBeenCalledWith('auth_refresh_ack', { + success: false, + error: 'Invalid token', + } as AuthRefreshAckPayload); + }); + + it('cleans up interval on disconnect', async () => { + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (socketService.validateSocketToken as jest.Mock).mockResolvedValue(true); + + registerLocationHandler(io, socket); + + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(1); + + // Simulate disconnect + const disconnectHandler = (socket.on as jest.Mock).mock.calls.find( + (call: string[]) => call[0] === 'disconnect', + )?.[1] as () => void; + + expect(disconnectHandler).toBeDefined(); + disconnectHandler!(); + + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + // Should not have been called again after cleanup + expect(socketService.validateSocketToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('deliveryRoom helper', () => { + it('prefixes the deliveryId with DELIVERY_ROOM_PREFIX', () => { + expect(deliveryRoom('abc123')).toBe('delivery:abc123'); + }); + + it('produces a unique room per deliveryId', () => { + const a = 'delivery-a'; + const b = 'delivery-b'; + expect(deliveryRoom(a)).not.toBe(deliveryRoom(b)); + }); + }); +}); diff --git a/tests/socket.service.test.ts b/tests/socket.service.test.ts index c1ba614..231510a 100644 --- a/tests/socket.service.test.ts +++ b/tests/socket.service.test.ts @@ -23,6 +23,14 @@ jest.mock('../src/config/logger', () => ({ debug: jest.fn(), })); +// Mock authService for token validation tests +jest.mock('../src/services/authService', () => ({ + verifyToken: jest.fn(), + getUserById: jest.fn(), +})); + +import authService from '../src/services/authService'; + /** * Build a minimal mock TypedSocket with only the fields the service needs. */ @@ -331,4 +339,77 @@ describe('SocketService', () => { service.stopHealthChecks(); }); }); + + // ── validateSocketToken ───────────────────────────────────────────────────── + + describe('validateSocketToken', () => { + it('returns true when socket has no token or userId', async () => { + const socket = makeMockSocket('no-token'); + const result = await service.validateSocketToken(socket); + expect(result).toBe(true); + }); + + it('returns true for a valid token with an active user', async () => { + const socket = makeMockSocket('valid-token'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-1'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-1' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'active' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(true); + expect(authService.verifyToken).toHaveBeenCalledWith('valid-token'); + expect(authService.getUserById).toHaveBeenCalledWith('user-1'); + }); + + it('returns false for an expired or invalid token', async () => { + const socket = makeMockSocket('expired-token'); + socket.data.token = 'expired-token'; + socket.data.userId = 'user-1'; + + (authService.verifyToken as jest.Mock).mockImplementation(() => { + throw new Error('Token expired'); + }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false when the user does not exist', async () => { + const socket = makeMockSocket('no-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-missing'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-missing' }); + (authService.getUserById as jest.Mock).mockResolvedValue(null); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false for a suspended user', async () => { + const socket = makeMockSocket('suspended-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-suspended'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-suspended' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'suspended' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + + it('returns false for a banned user', async () => { + const socket = makeMockSocket('banned-user'); + socket.data.token = 'valid-token'; + socket.data.userId = 'user-banned'; + + (authService.verifyToken as jest.Mock).mockReturnValue({ userId: 'user-banned' }); + (authService.getUserById as jest.Mock).mockResolvedValue({ status: 'banned' }); + + const result = await service.validateSocketToken(socket); + expect(result).toBe(false); + }); + }); });