feat: Implement proactive access token refreshing within the session and reactive token renewal for socket authentication errors.
This commit is contained in:
37
src/auth.ts
37
src/auth.ts
@@ -154,7 +154,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
token.role = (user as any).role;
|
token.role = (user as any).role;
|
||||||
token.accessToken = (user as any).accessToken;
|
token.accessToken = (user as any).accessToken;
|
||||||
token.refreshToken = (user as any).refreshToken;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -167,6 +203,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
(session.user as any).role = token.role;
|
(session.user as any).role = token.role;
|
||||||
(session.user as any).accessToken = token.accessToken;
|
(session.user as any).accessToken = token.accessToken;
|
||||||
(session.user as any).refreshToken = token.refreshToken;
|
(session.user as any).refreshToken = token.refreshToken;
|
||||||
|
(session.user as any).error = token.error;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,22 +65,31 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
const currentConversationIdRef = useRef<string | null>(null);
|
const currentConversationIdRef = useRef<string | null>(null);
|
||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
|
|
||||||
// Get access token from session
|
// Use localStorage token (same source as REST API, always fresh via interceptor)
|
||||||
const accessToken = session?.user?.accessToken;
|
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
|
// Connect to WebSocket
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
if (!accessToken) {
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
console.warn('No access token available for socket connection');
|
console.warn('No access token available for socket connection');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await socketService.connect(accessToken);
|
await socketService.connect(token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect to socket:', error);
|
console.error('Failed to connect to socket:', error);
|
||||||
}
|
}
|
||||||
}, [accessToken]);
|
}, [getAccessToken]);
|
||||||
|
|
||||||
// Disconnect from WebSocket
|
// Disconnect from WebSocket
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
@@ -89,7 +98,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
|
|
||||||
// Load conversations
|
// Load conversations
|
||||||
const loadConversations = useCallback(async () => {
|
const loadConversations = useCallback(async () => {
|
||||||
if (!accessToken) return;
|
if (!getAccessToken()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +109,7 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [accessToken]);
|
}, [getAccessToken]);
|
||||||
|
|
||||||
// Select a conversation and load its messages
|
// Select a conversation and load its messages
|
||||||
const selectConversation = useCallback(
|
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 () => {
|
return () => {
|
||||||
unsubscribeConnection();
|
unsubscribeConnection();
|
||||||
unsubscribeMessage();
|
unsubscribeMessage();
|
||||||
@@ -411,12 +453,13 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
unsubscribeTypingStop();
|
unsubscribeTypingStop();
|
||||||
unsubscribeStatus();
|
unsubscribeStatus();
|
||||||
unsubscribeRead();
|
unsubscribeRead();
|
||||||
|
unsubscribeAuthError();
|
||||||
};
|
};
|
||||||
}, [updateUserStatus]);
|
}, [updateUserStatus]);
|
||||||
|
|
||||||
// Auto-connect on mount
|
// Auto-connect on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoConnect && accessToken && !isInitializedRef.current) {
|
if (autoConnect && isAuthenticated && !isInitializedRef.current) {
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
connect();
|
connect();
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -424,11 +467,8 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
// Get initial unread count
|
// Get initial unread count
|
||||||
messagesService.getUnreadCount().then(setUnreadCount).catch(console.error);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [autoConnect, accessToken]);
|
}, [autoConnect, isAuthenticated]);
|
||||||
|
|
||||||
// Re-join conversation room when reconnecting
|
// Re-join conversation room when reconnecting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ class SocketService {
|
|||||||
private statusHandlers: Set<StatusHandler> = new Set();
|
private statusHandlers: Set<StatusHandler> = new Set();
|
||||||
private readHandlers: Set<ReadHandler> = new Set();
|
private readHandlers: Set<ReadHandler> = new Set();
|
||||||
private connectionHandlers: Set<(connected: boolean) => void> = new Set();
|
private connectionHandlers: Set<(connected: boolean) => void> = new Set();
|
||||||
|
private authErrorHandlers: Set<() => void> = new Set();
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
|
private currentToken: string | null = null;
|
||||||
|
|
||||||
connect(token: string): Promise<void> {
|
connect(token: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Already connected
|
this.currentToken = token;
|
||||||
|
|
||||||
|
// Already connected with same token
|
||||||
if (this.socket?.connected) {
|
if (this.socket?.connected) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
@@ -84,6 +88,12 @@ class SocketService {
|
|||||||
console.log('Socket disconnected:', reason);
|
console.log('Socket disconnected:', reason);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.notifyConnectionHandlers(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) => {
|
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 {
|
disconnect(): void {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
this.currentToken = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
@@ -225,6 +250,11 @@ class SocketService {
|
|||||||
return () => this.connectionHandlers.delete(handler);
|
return () => this.connectionHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAuthError(handler: () => void): () => void {
|
||||||
|
this.authErrorHandlers.add(handler);
|
||||||
|
return () => this.authErrorHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
private notifyConnectionHandlers(connected: boolean): void {
|
private notifyConnectionHandlers(connected: boolean): void {
|
||||||
this.connectionHandlers.forEach((handler) => handler(connected));
|
this.connectionHandlers.forEach((handler) => handler(connected));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user