diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 7cc43c07a..8b2118e2d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -39,6 +39,7 @@ import { parsedEnv } from './libs/env' import { initializeExternalDependency } from './libs/external-dependency' import { createRedis } from './libs/redis' import { resolveRequestAuth } from './libs/request-auth' +import { createUnauthorizedWsEvents } from './libs/ws-auth' import { sessionMiddleware } from './middlewares/auth' import { emitOtelLog, initOtel } from './otel' import { registerActiveSessionsGauge } from './otel/gauges/active-sessions' @@ -66,7 +67,7 @@ import { createProviderService } from './services/providers' import { createRequestLogService } from './services/request-log' import { createStripeService } from './services/stripe' import { createUserDeletionService } from './services/user-deletion' -import { ApiError, createInternalError, createUnauthorizedError } from './utils/error' +import { ApiError, createInternalError } from './utils/error' import { nanoid } from './utils/id' import { getTrustedOrigin } from './utils/origin' @@ -144,17 +145,17 @@ export async function buildApp(deps: AppDeps) { app.get('/ws/chat', upgradeWebSocket(async (c) => { const token = c.req.query('token') - if (!token) { - throw createUnauthorizedError('Missing token') - } + if (!token) + return createUnauthorizedWsEvents() + const session = await resolveRequestAuth( deps.auth, deps.env, new Headers({ Authorization: `Bearer ${token}` }), ) - if (!session?.user) { - throw createUnauthorizedError('Invalid token') - } + if (!session?.user) + return createUnauthorizedWsEvents() + return chatWsSetup(session.user.id) })) diff --git a/apps/server/src/libs/ws-auth.test.ts b/apps/server/src/libs/ws-auth.test.ts new file mode 100644 index 000000000..3efb4ecdc --- /dev/null +++ b/apps/server/src/libs/ws-auth.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createUnauthorizedWsEvents, WS_CLOSE_UNAUTHORIZED } from './ws-auth' + +describe('createUnauthorizedWsEvents', () => { + // ROOT CAUSE: + // + // Throwing `createUnauthorizedError` inside the `upgradeWebSocket` factory + // returns HTTP 401 before the upgrade completes. Browsers do not surface + // that status to the WebSocket `close` event — clients only see + // `code=1006` (abnormal closure), indistinguishable from a transient + // network drop. VueUse's `useWebSocket.autoReconnect` then keeps retrying + // the same stale token forever (the symptom captured in the original + // `/ws/chat?token=...` 401 storm logs). + // + // Accepting the upgrade first and closing with 4001 in `onOpen` gives + // the client a structured signal so its `onDisconnected` handler can + // stop the reconnect loop until the URL (token) actually changes. + it('closes the socket with WS_CLOSE_UNAUTHORIZED immediately on open', () => { + const close = vi.fn<(code?: number, reason?: string) => void>() + const events = createUnauthorizedWsEvents() + + // Hono's WSEvents allows partial maps; only onOpen is set here. + expect(events.onOpen).toBeDefined() + events.onOpen!(new Event('open'), { close } as any) + + expect(close).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledWith(WS_CLOSE_UNAUTHORIZED, 'unauthorized') + expect(WS_CLOSE_UNAUTHORIZED).toBe(4001) + }) +}) diff --git a/apps/server/src/libs/ws-auth.ts b/apps/server/src/libs/ws-auth.ts new file mode 100644 index 000000000..3ab497888 --- /dev/null +++ b/apps/server/src/libs/ws-auth.ts @@ -0,0 +1,50 @@ +import type { WSEvents } from 'hono/ws' + +/** + * Custom application close code (IANA private range 4000-4999) used to + * signal "auth failed, do not reconnect with this token". + * + * Use when: + * - Rejecting a WebSocket connection because the bearer token in the + * `?token=` query parameter is missing, invalid, expired, or revoked. + * + * Expects: + * - Server: accept the upgrade first, then close with this code inside + * `onOpen`. Throwing inside `upgradeWebSocket` produces an HTTP 401 + * that browsers swallow before the connection enters the WS state + * machine, leaving clients with only `code=1006` — indistinguishable + * from a transient network drop. + * - Client: in `onDisconnected`, treat `ev.code === 4001` as a terminal + * auth failure for the current token and stop the autoReconnect loop + * until a fresh token rotates the URL. + * + * NOTICE: + * - This constant is duplicated in + * `packages/stage-ui/src/libs/chat-sync/ws-client.ts` as + * `WS_CLOSE_UNAUTHORIZED`. The two MUST stay in sync; pulling a shared + * package in for one constant is more cost than risk here, so we + * document the contract on both sides instead. + */ +export const WS_CLOSE_UNAUTHORIZED = 4001 + +/** + * Build a `WSEvents` shape that immediately closes the socket with + * `WS_CLOSE_UNAUTHORIZED` after the upgrade completes. + * + * Use when: + * - A `upgradeWebSocket(async (c) => ...)` factory has determined that + * the caller is not authenticated and wants to surface that to the + * client as a structured close code (not an HTTP 401 rejection of + * the upgrade itself). + * + * Returns: + * - A `WSEvents` object with only `onOpen` populated. The hono/ws helper + * accepts partial event maps. + */ +export function createUnauthorizedWsEvents(): WSEvents { + return { + onOpen(_evt, ws) { + ws.close(WS_CLOSE_UNAUTHORIZED, 'unauthorized') + }, + } +} diff --git a/packages/stage-ui/src/libs/chat-sync/ws-client.test.ts b/packages/stage-ui/src/libs/chat-sync/ws-client.test.ts index e4ff8c791..f7cdf0b1a 100644 --- a/packages/stage-ui/src/libs/chat-sync/ws-client.test.ts +++ b/packages/stage-ui/src/libs/chat-sync/ws-client.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildChatWsUrl, computeReconnectDelay, mapStatus } from './ws-client' +import { buildChatWsUrl, computeReconnectDelay, mapStatus, WS_CLOSE_UNAUTHORIZED } from './ws-client' describe('buildChatWsUrl', () => { /** @@ -106,3 +106,22 @@ describe('mapStatus', () => { expect(mapStatus('CLOSED', false)).toBe('idle') }) }) +describe('wS_CLOSE_UNAUTHORIZED', () => { + // ROOT CAUSE: + // + // Browsers do not expose the HTTP 401 status to the WebSocket `close` + // event when an upgrade is rejected — the only signal a client gets is + // `code=1006` (abnormal closure), indistinguishable from a transient + // network drop. VueUse's `useWebSocket.autoReconnect` then hammers the + // same stale token forever. + // + // The server accepts the upgrade and closes with this custom code so + // the client can distinguish "auth failed, stop reconnecting" from + // "network blip, keep retrying". The matching constant on the server + // lives at `apps/server/src/libs/ws-auth.ts:WS_CLOSE_UNAUTHORIZED` and + // is exercised by `apps/server/src/libs/ws-auth.test.ts`. If either + // value drifts the close-code contract breaks silently. + it('matches the server-side close code contract (4001, IANA private range)', () => { + expect(WS_CLOSE_UNAUTHORIZED).toBe(4001) + }) +}) diff --git a/packages/stage-ui/src/libs/chat-sync/ws-client.ts b/packages/stage-ui/src/libs/chat-sync/ws-client.ts index 4ef8b4996..b46954302 100644 --- a/packages/stage-ui/src/libs/chat-sync/ws-client.ts +++ b/packages/stage-ui/src/libs/chat-sync/ws-client.ts @@ -13,6 +13,17 @@ const RECONNECT_BASE_MS = 1000 const RECONNECT_MAX_MS = 30_000 const RECONNECT_RETRIES = -1 +/** + * Server-side auth rejection close code (IANA application range 4000-4999). + * + * Browsers swallow the HTTP 401 status when a WebSocket upgrade is rejected, + * so the only way for the server to distinguish "wrong token, stop retrying" + * from a transient network drop on the client is to accept the upgrade and + * close with a custom application code. The server emits this from + * `apps/server/src/app.ts` when `resolveRequestAuth` returns null. + */ +export const WS_CLOSE_UNAUTHORIZED = 4001 + // NOTICE: // The native ws adapter's context type is not directly exported from // `@moeru/eventa/adapters/websocket/native`; use the inferred return type so @@ -261,8 +272,24 @@ export function createChatWsClient(options: CreateChatWsClientOptions): ChatWsCl context.value = created.context attachContextListeners(created.context) }, - onDisconnected() { + onDisconnected(_rawWs, ev) { disposeContext() + // ROOT CAUSE: + // + // useWebSocket's autoReconnect treats every onclose as worth + // retrying. When the server rejects auth, the only structured + // signal we get is the close `code` (the close `reason` body is + // also delivered but not used for routing here). 4001 is our + // contract with apps/server/src/app.ts for "this token will never + // succeed without rotation"; calling `ws.close()` here sets + // useWebSocket's internal `explicitlyClosed` flag so the next + // onclose path skips the reconnect schedule. The next time + // `urlRef` changes (token refresh), `watch(urlRef, open)` calls + // `open()` which resets `explicitlyClosed` to false and re-inits. + if (ev.code === WS_CLOSE_UNAUTHORIZED) { + console.warn('[chat-ws] server rejected auth (4001), pausing reconnect until token rotates') + ws.close() + } }, onError(_rawWs, event) { console.warn('[chat-ws] ws error event:', event)