Search code examples
reactjssignalrhttponly

handle JWT refresh with SignalR when using HttpOnly cookies


I'm using SignalR in a React frontend with JWT tokens passed via HttpOnly cookies for authentication, which prevents access to the token in JavaScript.

When the server is down for too long, the JWT expire and need to be refreshed.

I have a working refresh-Token endpoint (using httpOnly too).

here is my setup :
this.connection = new signalR.HubConnectionBuilder()
    .withUrl(serverUrl)
    .withAutomaticReconnect()
    .build();

await this.connection.start();

this.connection.onclose(() => {
    console.warn("SignalR Disconnected");
});

this.connection.onreconnecting((error) => {
    console.warn("SignalR Reconnecting...", error);
});

this.connection.onreconnected((connectionId) => {
    console.info(`SignalR Reconnected. Connection ID: ${connectionId}`);
});

Context:

  • JWTs are passed exclusively via HttpOnly cookies, so accessTokenFactory cannot be used as per Microsoft's documentation.

  • A similar discussion exists here, but it focuses on accessTokenFactory, which isn't applicable with HttpOnly cookies. There is one solution exposed here And I am wondering if there nothing else that could be done 3 years later.

Please avoid any "check if the message contains '401' or 'unauthorized'".

Question:

Is there a standard or recommended approach to handle JWT refresh with SignalR when using HttpOnly cookies, without fully re-implementing SignalR connection management logic?

Thank you.


Solution

  • I ended up with this approach, which is far from optimal but "kind of works":

    1. When stopping the connection, if it's not an explicit stop (e.g., logout), ping an authenticated endpoint to verify the session.
    2. If the server responds with a 401, attempt a token refresh.
    3. If the refresh succeeds, restart the SignalR connection manually.
    4. For explicit stops, ensure a stopConnectionCalled boolean is set to avoid unnecessary token refresh attempts during nominal disconnections.

    Code:

    // The `stopConnectionCalled` field prevents refresh attempts during intentional disconnections (e.g., logout).
    private stopConnectionCalled: boolean = false;
    
    async initConnection() {
        // Reset `stopConnectionCalled` whenever initializing the connection.
        this.stopConnectionCalled = false;
    
        this.connection = new signalR.HubConnectionBuilder()
            .withUrl(serverUrl)
            .withAutomaticReconnect()
            .build();
    
        await this.connection.start();
    
        this.connection.onclose(async () => {
            // Attempt to recover the connection only if it wasn't manually stopped.
            if (!this.stopConnectionCalled) {
                const refreshedSuccessfully = await this.tryIfRefreshTokenNeeded(id);
                if (refreshedSuccessfully) {
                    // Restart the SignalR connection manually after token refresh.
                    await this.connection?.start();
                }
            }
        });
    }
    
    async stopConnection() {
        // Mark as explicitly stopped to prevent unnecessary recovery attempts.
        this.stopConnectionCalled = true;
        // ...nominal stop connection logic
    }
    

    'Pinger' Function:

    This function checks if a token refresh is needed by pinging an authenticated endpoint and attempts to refresh the token if required:

    private async tryIfRefreshTokenNeeded(id: string): Promise<boolean> {
        try {
            // Ping the server with an authenticated request.
            await api.server.pingAuthenticatedNoAutoRefresh();
        } catch (error) {
            // Handle 401 Unauthorized responses by refreshing the token.
            if (error instanceof Response && error.status === 401) {
                // 401 Unauthorized detected on ping, attempting token refresh
                try {
                    await api.account.refreshToken();
                    return true; // Refresh succeeded
                } catch {
                    return false; // failed to refresh the token
                }
            }
            return false; // Any other error
        }
        return true; // No refresh needed
    }
    

    While this solution is a starting point, I am still open to a more robust approach.