From 50e593d7aed0c1be81d9c2f4aca95d2f4c09d108 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Wed, 11 Feb 2026 04:47:51 +0530 Subject: [PATCH] feat: Implement proactive access token refreshing within the session and reactive token renewal for socket authentication errors. --- src/auth.ts | 37 ++++++++++++++++++++ src/hooks/useMessaging.ts | 64 +++++++++++++++++++++++++++------- src/services/socket.service.ts | 32 ++++++++++++++++- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 171b877..df0c178 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -154,7 +154,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ token.role = (user as any).role; token.accessToken = (user as any).accessToken; token.refreshToken = (user as any).refreshToken; + // Store when the token was issued for expiration tracking + token.accessTokenIssuedAt = Date.now(); } + + // Auto-refresh the access token if it's about to expire (refresh 2 minutes before expiry) + // Backend JWT_ACCESS_EXPIRATION=15m, so refresh at ~13 minutes + const ACCESS_TOKEN_MAX_AGE = 13 * 60 * 1000; // 13 minutes in ms + const issuedAt = (token.accessTokenIssuedAt as number) || 0; + if (token.accessToken && Date.now() - issuedAt > ACCESS_TOKEN_MAX_AGE) { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: token.refreshToken, + }), + } + ); + + const data = await res.json(); + + if (res.ok && data.success) { + token.accessToken = data.data.accessToken; + token.refreshToken = data.data.refreshToken; + token.accessTokenIssuedAt = Date.now(); + } else { + // Refresh token is also expired - force re-login + token.error = "RefreshTokenExpired"; + } + } catch (error) { + console.error("Token refresh error:", error); + token.error = "RefreshTokenError"; + } + } + return token; }, @@ -167,6 +203,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ (session.user as any).role = token.role; (session.user as any).accessToken = token.accessToken; (session.user as any).refreshToken = token.refreshToken; + (session.user as any).error = token.error; } return session; }, diff --git a/src/hooks/useMessaging.ts b/src/hooks/useMessaging.ts index 3d0e026..534efa5 100644 --- a/src/hooks/useMessaging.ts +++ b/src/hooks/useMessaging.ts @@ -65,22 +65,31 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet const currentConversationIdRef = useRef(null); const isInitializedRef = useRef(false); - // Get access token from session - const accessToken = session?.user?.accessToken; + // Use localStorage token (same source as REST API, always fresh via interceptor) + const getAccessToken = useCallback(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('accessToken'); + } + return null; + }, []); + + // Check if user is authenticated (session exists) + const isAuthenticated = !!session?.user; // Connect to WebSocket const connect = useCallback(async () => { - if (!accessToken) { + const token = getAccessToken(); + if (!token) { console.warn('No access token available for socket connection'); return; } try { - await socketService.connect(accessToken); + await socketService.connect(token); } catch (error) { console.error('Failed to connect to socket:', error); } - }, [accessToken]); + }, [getAccessToken]); // Disconnect from WebSocket const disconnect = useCallback(() => { @@ -89,7 +98,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet // Load conversations const loadConversations = useCallback(async () => { - if (!accessToken) return; + if (!getAccessToken()) return; setIsLoading(true); try { @@ -100,7 +109,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet } finally { setIsLoading(false); } - }, [accessToken]); + }, [getAccessToken]); // Select a conversation and load its messages const selectConversation = useCallback( @@ -404,6 +413,39 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet } ); + // Handle auth errors (token expired, server disconnected us) + const unsubscribeAuthError = socketService.onAuthError(async () => { + console.log('Socket auth error detected, refreshing token via REST...'); + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + console.error('No refresh token available for socket reconnection'); + return; + } + // Refresh token using the same endpoint as the REST API interceptor + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + } + ); + const data = await res.json(); + if (res.ok && data.success) { + const newToken = data.data.accessToken; + localStorage.setItem('accessToken', newToken); + localStorage.setItem('refreshToken', data.data.refreshToken); + console.log('Token refreshed, reconnecting socket...'); + socketService.reconnectWithToken(newToken); + } else { + console.error('Token refresh failed:', data.message); + } + } catch (error) { + console.error('Failed to refresh token for socket reconnection:', error); + } + }); + return () => { unsubscribeConnection(); unsubscribeMessage(); @@ -411,12 +453,13 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet unsubscribeTypingStop(); unsubscribeStatus(); unsubscribeRead(); + unsubscribeAuthError(); }; }, [updateUserStatus]); // Auto-connect on mount useEffect(() => { - if (autoConnect && accessToken && !isInitializedRef.current) { + if (autoConnect && isAuthenticated && !isInitializedRef.current) { isInitializedRef.current = true; connect(); loadConversations(); @@ -424,11 +467,8 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet // Get initial unread count messagesService.getUnreadCount().then(setUnreadCount).catch(console.error); } - - // Don't disconnect on cleanup - socket is singleton and should stay connected - // Disconnect only happens when user logs out (handled elsewhere) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoConnect, accessToken]); + }, [autoConnect, isAuthenticated]); // Re-join conversation room when reconnecting useEffect(() => { diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts index a003a69..44f9e68 100644 --- a/src/services/socket.service.ts +++ b/src/services/socket.service.ts @@ -22,11 +22,15 @@ class SocketService { private statusHandlers: Set = new Set(); private readHandlers: Set = new Set(); private connectionHandlers: Set<(connected: boolean) => void> = new Set(); + private authErrorHandlers: Set<() => void> = new Set(); private isConnecting = false; + private currentToken: string | null = null; connect(token: string): Promise { return new Promise((resolve, reject) => { - // Already connected + this.currentToken = token; + + // Already connected with same token if (this.socket?.connected) { resolve(); return; @@ -84,6 +88,12 @@ class SocketService { console.log('Socket disconnected:', reason); this.isConnecting = false; this.notifyConnectionHandlers(false); + + // "io server disconnect" means the server rejected us (likely auth failure) + if (reason === 'io server disconnect') { + console.warn('Socket: Server disconnected us (likely token expired). Requesting token refresh...'); + this.authErrorHandlers.forEach((handler) => handler()); + } }); this.socket.on('connect_error', (error) => { @@ -118,11 +128,26 @@ class SocketService { }); } + // Reconnect with a fresh token (used when the previous token expired) + reconnectWithToken(token: string): void { + this.currentToken = token; + if (this.socket) { + // Update the auth token for reconnection + this.socket.auth = { token }; + this.socket.connect(); + } else { + this.connect(token).catch((err) => { + console.error('Socket reconnect with new token failed:', err); + }); + } + } + disconnect(): void { if (this.socket) { this.socket.disconnect(); this.socket = null; } + this.currentToken = null; } isConnected(): boolean { @@ -225,6 +250,11 @@ class SocketService { return () => this.connectionHandlers.delete(handler); } + onAuthError(handler: () => void): () => void { + this.authErrorHandlers.add(handler); + return () => this.authErrorHandlers.delete(handler); + } + private notifyConnectionHandlers(connected: boolean): void { this.connectionHandlers.forEach((handler) => handler(connected)); }