javaspring-bootvuejs3nuxt3.js

Properly proxy WebSocket connection through Nuxt 3 Server


I want to proxy WebSocket Connections through a Nuxt 3 Nitro Server to enrich it with Session information for authentication. I managed to get it working to a point, where the server intercepts the request, enriches it with an Authorization-Header and proxies it to a Spring Boot Backend. But the Backend is not able to process the request properly. At least thats my interpretation. In my Backend logs I have the following Log entries:

2024-11-16T12:06:31.433+01:00 DEBUG 10368 --- [nio-3001-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/ws", parameters={}
2024-11-16T12:06:31.436+01:00 DEBUG 10368 --- [nio-3001-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping      : Mapped to org.springframework.web.socket.server.support.WebSocketHttpRequestHandler@3d20e575
2024-11-16T12:06:31.441+01:00 DEBUG 10368 --- [nio-3001-exec-1] o.s.w.s.s.s.WebSocketHttpRequestHandler  : GET /ws
2024-11-16T12:06:31.454+01:00 DEBUG 10368 --- [nio-3001-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 101 SWITCHING_PROTOCOLS
2024-11-16T12:06:31.461+01:00 DEBUG 10368 --- [nio-3001-exec-1] s.w.s.h.LoggingWebSocketHandlerDecorator : New StandardWebSocketSession[id=29da51eb-03a2-0697-900f-278000eff67a, uri=ws://localhost:3001/ws]
2024-11-16T12:06:31.463+01:00 DEBUG 10368 --- [nio-3001-exec-1] s.w.s.h.LoggingWebSocketHandlerDecorator : Transport error in StandardWebSocketSession[id=29da51eb-03a2-0697-900f-278000eff67a, uri=ws://localhost:3001/ws]

java.io.EOFException: null
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.fillReadBuffer(NioEndpoint.java:1296) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.read(NioEndpoint.java:1184) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.websocket.server.WsFrameServer.onDataAvailable(WsFrameServer.java:74) ~[tomcat-embed-websocket-10.1.19.jar:10.1.19]
    at org.apache.tomcat.websocket.server.WsFrameServer.doOnDataAvailable(WsFrameServer.java:184) ~[tomcat-embed-websocket-10.1.19.jar:10.1.19]
    at org.apache.tomcat.websocket.server.WsFrameServer.notifyDataAvailable(WsFrameServer.java:164) ~[tomcat-embed-websocket-10.1.19.jar:10.1.19]
    at org.apache.tomcat.websocket.server.WsHttpUpgradeHandler.upgradeDispatch(WsHttpUpgradeHandler.java:152) ~[tomcat-embed-websocket-10.1.19.jar:10.1.19]
    at org.apache.coyote.http11.upgrade.UpgradeProcessorInternal.dispatch(UpgradeProcessorInternal.java:60) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:57) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:896) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1744) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) ~[tomcat-embed-core-10.1.19.jar:10.1.19]
    at java.base/java.lang.Thread.run(Thread.java:1570) ~[na:na]

2024-11-16T12:06:31.467+01:00 DEBUG 10368 --- [nio-3001-exec-1] s.w.s.h.LoggingWebSocketHandlerDecorator : StandardWebSocketSession[id=29da51eb-03a2-0697-900f-278000eff67a, uri=ws://localhost:3001/ws] closed with CloseStatus[code=1006, reason=null]
2024-11-16T12:06:31.467+01:00 DEBUG 10368 --- [nio-3001-exec-1] o.s.w.s.m.SubProtocolWebSocketHandler    : Clearing session 29da51eb-03a2-0697-900f-278000eff67a
2024-11-16T12:06:31.470+01:00 DEBUG 10368 --- [nboundChannel-2] org.springframework.web.SimpLogging      : Processing DISCONNECT session=29da51eb-03a2-0697-900f-278000eff67a
2024-11-16T12:06:31.470+01:00 DEBUG 10368 --- [tboundChannel-1] o.s.w.s.m.SubProtocolWebSocketHandler    : No session for GenericMessage [payload=byte[0], headers={simpMessageType=DISCONNECT_ACK, simpDisconnectMessage=GenericMessage [payload=byte[0], headers={simpMessageType=DISCONNECT, stompCommand=DISCONNECT, simpSessionAttributes={org.springframework.messaging.simp.SimpAttributes.COMPLETED=true}, simpSessionId=29da51eb-03a2-0697-900f-278000eff67a}], simpSessionId=29da51eb-03a2-0697-900f-278000eff67a}]
2024-11-16T12:07:25.523+01:00  INFO 10368 --- [MessageBroker-1] o.s.w.s.c.WebSocketMessageBrokerStats    : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 1 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 3], outboundChannel[pool size = 1, active threads = 0, queued tasks = 0, completed tasks = 1], sockJsScheduler[pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]

I created a minimal reproducible sample here: https://github.com/r4id4h/ws-repro.git

Why is the connection not established? In the sample there are two buttons. One is establishing the connection using the proxy and the other one directly. The direct connection is working but the proxy is not. What am I missing here?


Solution

  • I worked around the issue and got it working. It's not an optimal solution but at least it's working and leveraging Spring Security for Authentication. The process to decrypt the token and add it to the request is pretty nasty but I struggled to decrypt the accessToken in time and add it to the request before it's sent (even if i did it in a synchronous way). Also to mention, the first request is always failing since the http-proxy doesn't recognize it as a websocket request, I don't really know why but for now it's not bothering me that much.

    I need further testing to check if the solution introduce a memory leak but for now I'm happy that it's working. I will look into this after I complete other implementations. I would be happy to hear suggestions to improve the code :)

    Solution:

    import {defineEventHandler} from 'h3';
    import {decryptToken} from "@/node_modules/nuxt-oidc-auth/dist/runtime/server/utils/security";
    import {createProxyMiddleware} from 'http-proxy-middleware';
    import {unseal, defaults} from 'iron-webcrypto';
    import {webcrypto} from 'node:crypto';
    
    const simpleRequestLogger = (proxyServer, options) => {
        proxyServer.on('open', (proxySocket) => {
            console.log('[HPM] WebSocket connection established.');
        });
        proxyServer.on('proxyReq', (proxyReq, req, res) => {
            console.log(`[HPM] [${req.method}] ${req.url}`);
            console.log('proxyReq', proxyReq);
        });
        proxyServer.on('error', (e) => {
            console.error('[HPM Error]', e);
        });
    };
    
    export default defineEventHandler(async (event) => {
        const {req, res} = event.node;
        console.log('[HPM] WebSocket connection detected.');
        console.log('[HPM] Incoming request headers:', req.headers);
    
        if (req.headers.upgrade?.toLowerCase() === 'websocket') {
            try {
                console.log('[HPM] Authorization header pre-fetched and added to the request.');
    
                const wsProxy = createProxyMiddleware({
                    target: 'wss://localhost:3001',
                    ws: true,
                    pathFilter: '/**',
                    secure: true,
                    changeOrigin: true,
                    plugins: [simpleRequestLogger],
                    headers: {},
                    on: {
                        proxyReqWs: (proxyReq, req, socket, options, head, asyncContext) => {
                            asyncContext(async () => {
                                const cookies = parseCookies(req.headers.cookie);
                                const sessionCookie = cookies['nuxt-oidc-auth'];
    
                                if (!sessionCookie) {
                                    console.error('[HPM] nuxt-oidc-auth cookie not found.');
                                    return;
                                }
    
                                const accessToken = await fetchAccessToken(sessionCookie);
                                proxyReq.setHeader("Authorization", accessToken);
                            });
                            console.log('[HPM] WebSocket request detected.');
                        },
                        close: (proxyRes, proxySocket, proxyHead) => {
                            proxySocket.removeAllListeners();
                            proxySocket.destroy();
                        }
                    }
                });
    
                await wsProxy(req, res);
                return;
            } catch (err) {
                console.error('[HPM] Failed to fetch Authorization header:', err);
                res.statusCode = 500;
                res.end('Internal Server Error');
                return;
            }
        }
    });
    
    function parseCookies(cookieHeader: string | undefined): Record<string, string> {
        if (!cookieHeader) {
            return {};
        }
        return cookieHeader.split(';').reduce((cookies, cookie) => {
            const [key, value] = cookie.split('=').map(part => part.trim());
            cookies[key] = decodeURIComponent(value);
            return cookies;
        }, {} as Record<string, string>);
    }
    
    async function fetchAccessToken(sessionCookie: string): Promise<string> {
        const password = process.env.NUXT_OIDC_SESSION_SECRET;
        const unsealedCookie = await unseal(webcrypto, sessionCookie, password, defaults);
        const persistentSession = await useStorage('oidc').getItem(unsealedCookie.id);
    
        if (!persistentSession) {
            throw new Error('[HPM] Persistent session not found.');
        }
    
        const tokenKey = process.env.NUXT_OIDC_TOKEN_KEY;
        return `Bearer ${await decryptToken(persistentSession.accessToken, tokenKey!)}`;
    }
    

    Edit: Just as a side note this code is only fetching accessToken once, it's never updated. It's something I need to look further into, I will update the code if I have a solution.

    Edit 2: I ended up forking http-proxy, http-proxy-middleware and @types/http-proxy to introduce async behaviour by merging an open PR from the original Repo into my forked one. I updated my solution to point out how the final solution looks like. But as described it won't work with the default http-proxy-middleware. Maybe at some point when I have some free time I will add the forked libs to npm. There's still room for improvements on the code and I'm happy to hear other opinions.