fix(server): ws conn 401

This commit is contained in:
RainbowBird
2026-05-18 17:05:49 +08:00
parent f33d485b82
commit 982da671ef
5 changed files with 137 additions and 9 deletions
+8 -7
View File
@@ -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)
}))
+31
View File
@@ -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)
})
})
+50
View File
@@ -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')
},
}
}