fix(server): ws conn 401
This commit is contained in:
@@ -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)
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user