Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/controllers/deliveryStatusController.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'));
}
Expand Down
3 changes: 2 additions & 1 deletion src/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 30 additions & 6 deletions src/sockets/connectionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -135,10 +137,7 @@ export async function shutdownSocketServer(io: TypedServer): Promise<void> {
* Extract an authenticated user ID from the socket handshake.
*
* Clients should pass their JWT in the `auth` object:
* `socket = io(url, { auth: { token: 'Bearer <jwt>' } })`
*
* 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.
Expand All @@ -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 <jwt>' } })`
*
* @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<string, unknown>;

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;
}
123 changes: 123 additions & 0 deletions src/sockets/locationHandler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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,
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
AuthExpiredPayload,
AuthRefreshPayload,
AuthRefreshAckPayload,
} from './socket.types';

/**
Expand Down Expand Up @@ -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<void> => {
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);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions src/sockets/socket.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Server as SocketIOServer } from 'socket.io';
import authService from '../services/authService';
import logger from '../config/logger';
import {
SocketConnectionMeta,
Expand Down Expand Up @@ -253,6 +254,55 @@ export class SocketService {
public getConnections(): ReadonlyMap<string, SocketConnectionMeta> {
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<boolean> {
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. */
Expand Down
31 changes: 31 additions & 0 deletions src/sockets/socket.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
}

/**
Expand All @@ -187,6 +217,7 @@ export interface InterServerEvents {
*/
export interface SocketData {
userId?: string;
token?: string;
connectedAt: number;
}

Expand Down
Loading