I’m working on a web application that uses SignalR with WebSockets for real-time communication. When establishing a connection, I first call a negotiate endpoint to get a URL and an access token, which is then used to create the SignalR connection.
I’ve noticed that even when the token expires, my SignalR WebSocket connection continues to function if I don’t explicitly reconnect. However, I’m concerned about the security and correctness of this behavior.
My questions are:
Should I call the negotiate endpoint again to obtain a new access token once the current token has expired, even if the WebSocket connection is still active?
If the token expires but the WebSocket connection is still working, is it safe to continue using the same connection, or should I always re-negotiate to ensure the token is fresh?
What is the best practice for handling expired tokens in a long-running SignalR WebSocket connection?
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { toast } from 'react-toastify';
import { negotiateSignalR } from '@my/services'; // Custom service for negotiation
import { extractErrorMessage } from '@my/utils'; // Custom error message extractor
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
interface SignalRContextType {
connection: HubConnection;
}
const SignalRContext = createContext<SignalRContextType | undefined>(undefined);
export const SignalRProvider = ({ children }: { children: ReactNode }) => {
const [connection, setConnection] = useState<HubConnection | null>(null);
const hasReconnected = useRef<boolean>(false);
const createConnection = useCallback(
async (url: string, accessToken: string) => {
const newConnection = new HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: async () => accessToken,
})
.withAutomaticReconnect()
.build();
newConnection.off('ReceiveMessage');
newConnection.onreconnecting(() => handleReconnection(newConnection));
try {
await newConnection.start();
setConnection(newConnection);
} catch (error) {
toast.error(extractErrorMessage(error));
}
},
[]
);
const setupConnection = useCallback(async () => {
try {
const response = await negotiateSignalR();
const { url, accessToken } = response.data;
if (url && accessToken) {
await createConnection(url, accessToken);
}
} catch (error) {
toast.error(extractErrorMessage(error));
}
}, [createConnection]);
const handleReconnection = useCallback(
async (newConnection: HubConnection) => {
console.log('Attempting to reconnect...');
if (hasReconnected.current) {
console.log('Reconnection attempt already made, skipping further attempts.');
return;
}
hasReconnected.current = true;
try {
await newConnection.stop(); // Stop the current connection on any error
await setupConnection(); // Reuse setupConnection to create a new connection
console.log('Reconnected after error and restarted connection');
hasReconnected.current = false; // Reset after successful reconnection
} catch (reconnectError) {
toast.error('Failed to reconnect after error.');
console.error('Error during reconnection:', reconnectError);
}
},
[setupConnection]
);
useEffect(() => {
setupConnection();
}, [setupConnection]);
return (
<SignalRContext.Provider value={{ connection }}>
{children}
</SignalRContext.Provider>
);
};
export const useSignalR = (): SignalRContextType => {
const context = useContext(SignalRContext);
if (!context) {
throw new Error('useSignalR must be used within a SignalRProvider');
}
return context;
};