Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions docs/errors/DTK0008.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ outline: deep

## Cause

This warning is emitted by `createWsServer()` when the WebSocket server starts and client authentication has been disabled. Authentication is disabled when any of the following conditions is true:
This warning is emitted when the DevTools hub starts and client authentication has been fully disabled. Authentication is disabled when either of the following is true:

1. The DevTools context is running in **build mode** (`context.mode === 'build'`).
2. The Vite config sets `devtools.config.clientAuth` to `false`.
3. The environment variable `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` is set to `'true'`.
1. The Vite config sets `devtools.config.clientAuth` to `false`.
2. The environment variable `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` is set to `'true'`.

When authentication is disabled, every connecting WebSocket client is automatically marked as trusted (`meta.isTrusted = true`), bypassing the token-based auth flow entirely.

Build mode does **not** disable authentication: the standalone build viewer keeps the auth gate installed and trusts clients via an unguessable per-process capability token baked into the locally-served connection metadata. The zero-prompt UX is preserved without trusting arbitrary clients.

## Example

```ts
Expand All @@ -43,22 +44,15 @@ Or via environment variable:
VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true vite dev
```

Build mode also disables auth automatically:

```sh
vite build # DTK0008 is logged during build
```

## Fix

This is an informational warning. No action is required if you intentionally disabled authentication (e.g., in a trusted local environment or during builds).
This is an informational warning. No action is required if you intentionally disabled authentication (e.g., in a trusted local environment).

If this warning is unexpected:

- Remove `clientAuth: false` from your `devtools.config` in `vite.config.ts`.
- Unset the `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` environment variable.
- If running in build mode, the warning is expected and harmless.

## Source

- [`packages/core/src/node/ws.ts`](https://github.com/vitejs/devtools/blob/main/packages/core/src/node/ws.ts) — `createWsServer()` logs this on startup when client authentication is bypassed (build mode, `clientAuth: false`, or `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`).
- [`packages/core/src/node/auth-handler.ts`](https://github.com/vitejs/devtools/blob/main/packages/core/src/node/auth-handler.ts) — `isClientAuthDisabled()` reports when the auth gate is bypassed (`clientAuth: false` or `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`).
53 changes: 50 additions & 3 deletions packages/core/src/node/__tests__/auth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import type { ResolvedConfig } from 'vite'
import type { DevToolsConfig } from '../config'
import process from 'node:process'
import { describe, expect, it, vi } from 'vitest'
import { getAuthHandler } from '../auth-handler'
import { getAuthHandler, getBuildCapabilityToken, isBuildCapabilityAuth, isClientAuthDisabled } from '../auth-handler'
import { createDevToolsContext } from '../context'
import '@vitejs/devtools-kit'

function createConfig(config?: Partial<DevToolsConfig>): ResolvedConfig {
function createConfig(config?: Partial<DevToolsConfig>, command: 'serve' | 'build' = 'serve'): ResolvedConfig {
return {
root: process.cwd(),
command: 'serve',
command,
plugins: [],
server: { port: 5173 },
devtools: config === undefined ? undefined : { config },
Expand Down Expand Up @@ -41,4 +41,51 @@ describe('getAuthHandler banner', () => {
log.mockRestore()
}
})

it('suppresses the OTP banner in implicit build mode (trust is token-based)', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const ctx = await createDevToolsContext(createConfig(undefined, 'build'))

try {
getAuthHandler(ctx).printBanner()
expect(log).not.toHaveBeenCalled()
}
finally {
log.mockRestore()
}
})
})

describe('build-mode capability token', () => {
it('flags implicit build mode as capability-token auth, not disabled', async () => {
const ctx = await createDevToolsContext(createConfig(undefined, 'build'))

expect(isBuildCapabilityAuth(ctx)).toBe(true)
expect(isClientAuthDisabled(ctx)).toBe(false)
})

it('is not capability-token auth in dev mode', async () => {
const ctx = await createDevToolsContext(createConfig())

expect(isBuildCapabilityAuth(ctx)).toBe(false)
})

it('leaves an explicit clientAuth:false opt-out fully disabled in build mode', async () => {
const ctx = await createDevToolsContext(createConfig({ clientAuth: false }, 'build'))

expect(isClientAuthDisabled(ctx)).toBe(true)
expect(isBuildCapabilityAuth(ctx)).toBe(false)
})

it('mints a stable, unguessable token per context', async () => {
const ctx = await createDevToolsContext(createConfig(undefined, 'build'))

const token = getBuildCapabilityToken(ctx)
expect(token).toMatch(/^[\w-]{20,}$/)
// Memoized: the same context always yields the same token.
expect(getBuildCapabilityToken(ctx)).toBe(token)

const other = await createDevToolsContext(createConfig(undefined, 'build'))
expect(getBuildCapabilityToken(other)).not.toBe(token)
})
})
13 changes: 9 additions & 4 deletions packages/core/src/node/__tests__/context-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ describe('createDevToolsContext auth registration', () => {
expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(true)
})

it('skips the interactive-auth handshake in build mode (regression #539)', async () => {
it('registers the interactive-auth handshake in build mode for capability-token trust (#552)', async () => {
const ctx = await createDevToolsContext(createConfig({ command: 'build' }))

// Left unregistered so devframe's `auth: false` auto-trust shim (armed
// by `createDevToolsHub`) can install its own noop handler and mark the
// session trusted — see `isClientAuthDisabled`.
// Build mode keeps the gate installed and trusts via a per-process
// capability token rather than a prompt — see `isBuildCapabilityAuth`.
expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(true)
})

it('skips the interactive-auth handshake in build mode when clientAuth is explicitly false', async () => {
const ctx = await createDevToolsContext(createConfig({ command: 'build', clientAuth: false }))

expect(ctx.rpc.definitions.has('anonymous:devframe:auth')).toBe(false)
})

Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/node/__tests__/server-build-capability-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createDevToolsHub } from '../server'

const initHub = vi.hoisted(() => vi.fn())
const hubMiddleware = vi.hoisted(() => vi.fn())

vi.mock('@devframes/hub/initiate', () => ({
initHub,
}))

vi.mock('@devframes/json-render-ui/hub', () => ({
jsonRenderUiRenderer: () => ({ type: 'json-render', file: '/builtin-json-render.mjs' }),
}))

vi.mock('../ui', () => ({
createViteDevToolsUi: () => ({}),
}))

const CAPABILITY_TOKEN = 'build-capability-token'

vi.mock('../auth-handler', () => ({
getAuthHandler: () => ({ rpcFunctions: [] }),
isClientAuthDisabled: () => false,
isBuildCapabilityAuth: () => true,
getBuildCapabilityToken: () => CAPABILITY_TOKEN,
}))

function fakeContext(): ViteDevToolsNodeContext {
return {
mode: 'build',
viteConfig: { devtools: undefined },
viteServer: undefined,
host: { provideConnectionMeta: vi.fn() },
} as unknown as ViteDevToolsNodeContext
}

function fakeRes(): ServerResponse & { body?: string, headers: Record<string, string> } {
const headers: Record<string, string> = {}
return {
headers,
setHeader: vi.fn((name: string, value: string) => {
headers[name.toLowerCase()] = value
}),
end: vi.fn(function (this: any, chunk?: string) {
this.body = chunk
}),
} as unknown as ServerResponse & { body?: string, headers: Record<string, string> }
}

describe('createDevToolsHub build-mode capability token', () => {
beforeEach(() => {
vi.clearAllMocks()
initHub.mockReturnValue({
ready: Promise.resolve(),
connectionMeta: () => ({ backend: 'websocket', websocket: { path: '__ws' } }),
nodeMiddleware: hubMiddleware,
close: vi.fn(),
})
})

it('installs the real auth handler rather than the auto-trust shim', async () => {
await createDevToolsHub({ context: fakeContext() })

expect(initHub.mock.calls[0]![0].auth).not.toBe(false)
})

it('bakes the capability token into the emitted connection meta', async () => {
const { getConnectionMeta } = await createDevToolsHub({ context: fakeContext() })

expect(getConnectionMeta()).toMatchObject({
backend: 'websocket',
authToken: CAPABILITY_TOKEN,
})
})

it('intercepts the top-level connection meta route with the token-augmented meta', async () => {
const { middleware } = await createDevToolsHub({ context: fakeContext() })

const res = fakeRes()
const next = vi.fn()
middleware({ url: '/__devtools/__connection.json' } as IncomingMessage, res, next)

expect(next).not.toHaveBeenCalled()
expect(hubMiddleware).not.toHaveBeenCalled()
expect(res.headers['content-type']).toBe('application/json')
expect(JSON.parse(res.body!)).toMatchObject({ authToken: CAPABILITY_TOKEN })
})

it('delegates every other route to the hub middleware', async () => {
const { middleware } = await createDevToolsHub({ context: fakeContext() })

const res = fakeRes()
const next = vi.fn()
middleware({ url: '/__devtools/index.html' } as IncomingMessage, res, next)

expect(hubMiddleware).toHaveBeenCalledOnce()
expect(res.end).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ vi.mock('../ui', () => ({
vi.mock('../auth-handler', () => ({
getAuthHandler: () => ({ rpcFunctions: [] }),
isClientAuthDisabled: () => false,
isBuildCapabilityAuth: () => false,
getBuildCapabilityToken: () => 'test-capability-token',
}))

function fakeContext(opts: { viteServer?: boolean } = {}): ViteDevToolsNodeContext {
Expand Down
74 changes: 61 additions & 13 deletions packages/core/src/node/auth-handler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import type { ViteDevToolsNodeContext } from '@vitejs/devtools-kit'
import type { DevToolsConfig } from './config'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'

export type DevToolsAuthHandler = ReturnType<typeof createInteractiveAuth>

const handlers = new WeakMap<ViteDevToolsNodeContext, DevToolsAuthHandler>()
const capabilityTokens = new WeakMap<ViteDevToolsNodeContext, string>()

/**
* The per-process capability token minted for an implicit build-mode context
* (see {@link isBuildCapabilityAuth}). Created lazily and memoized per context,
* so `getAuthHandler` (which registers it as an always-trusted
* `clientAuthTokens` entry) and `createDevToolsHub` (which bakes it into the
* locally-served connection metadata's `authToken`) hand out the exact same
* value. Unguessable and never printed — only a same-origin client able to read
* the served `__connection.json` learns it, so a cross-origin loopback page or
* an `Origin`-less local process stays untrusted.
*/
export function getBuildCapabilityToken(context: ViteDevToolsNodeContext): string {
let token = capabilityTokens.get(context)
if (!token) {
token = randomBytes(32).toString('base64url')
capabilityTokens.set(context, token)
}
return token
}

/**
* The interactive OTP auth handler for a context — created once and shared
Expand All @@ -14,33 +35,60 @@ const handlers = new WeakMap<ViteDevToolsNodeContext, DevToolsAuthHandler>()
* one-time-code banner). Backed by devframe's `createInteractiveAuth` recipe,
* so the `anonymous:devframe:auth*` handlers, `devframe:auth:revoke`, and the
* banner all come from upstream rather than being hand-rolled here.
*
* In implicit build mode ({@link isBuildCapabilityAuth}) the handler additionally
* trusts the per-process {@link getBuildCapabilityToken} — the build viewer
* presents it automatically from the served connection meta — and its OTP
* banner is suppressed, since trust comes purely from that token.
*/
export function getAuthHandler(context: ViteDevToolsNodeContext): DevToolsAuthHandler {
let handler = handlers.get(context)
if (!handler) {
const config = context.viteConfig.devtools?.config as DevToolsConfig | undefined
const buildCapability = isBuildCapabilityAuth(context)
const clientAuthTokens = config?.clientAuthTokens ? [...config.clientAuthTokens] : []
if (buildCapability)
clientAuthTokens.push(getBuildCapabilityToken(context))
handler = createInteractiveAuth(context, {
clientAuthTokens: config?.clientAuthTokens,
banner: config?.banner,
clientAuthTokens,
// Build mode trusts purely via the per-process capability token baked
// into the served connection meta, so silence the OTP console banner.
banner: buildCapability ? () => {} : config?.banner,
})
handlers.set(context, handler)
}
return handler
}

/**
* Whether the interactive OTP gate should stay off for this context — a
* build snapshot (nothing live to authorize against), an explicit
* `devtools: { clientAuth: false }`, or the `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH`
* escape-hatch env var. Shared between `createDevToolsContext` (which must
* skip registering the interactive-auth RPC functions so devframe's
* `auth: false` auto-trust shim can register `anonymous:devframe:auth`
* itself) and `createDevToolsHub` (which feeds the same intent to
* `initHub`'s transport-level `auth` option) — both need to agree, or the
* client's session never gets marked trusted.
* Whether the interactive OTP gate stays fully off for this context — an
* explicit `devtools: { clientAuth: false }` or the
* `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` escape-hatch env var. Both are deliberate
* user opt-outs that trust every accepted client. Shared between
* `createDevToolsContext` (which then skips registering the interactive-auth
* RPC functions so devframe's `auth: false` auto-trust shim can register
* `anonymous:devframe:auth` itself) and `createDevToolsHub` (which feeds the
* same intent to `initHub`'s transport-level `auth` option) — both need to
* agree, or the client's session never gets marked trusted.
*
* Implicit build mode is deliberately absent: it keeps the auth gate installed
* but trusts via a capability token instead of a prompt — see
* {@link isBuildCapabilityAuth}.
*/
export function isClientAuthDisabled(context: ViteDevToolsNodeContext): boolean {
return context.mode === 'build'
|| context.viteConfig.devtools?.config?.clientAuth === false
return context.viteConfig.devtools?.config?.clientAuth === false
|| process.env.VITE_DEVTOOLS_DISABLE_CLIENT_AUTH === 'true'
}

/**
* Whether this context uses the implicit build-mode capability-token posture:
* a build snapshot served by a live server (the standalone viewer) that keeps
* the zero-prompt UX but, instead of trusting all comers, requires the
* per-process {@link getBuildCapabilityToken}. Only the implicit `build` branch
* qualifies — the explicit `clientAuth: false` and
* `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` opt-outs ({@link isClientAuthDisabled})
* still disable the gate entirely.
*/
export function isBuildCapabilityAuth(context: ViteDevToolsNodeContext): boolean {
return context.mode === 'build' && !isClientAuthDisabled(context)
}
15 changes: 9 additions & 6 deletions packages/core/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,15 @@ export async function createDevToolsContext(
// recipe: registers the `anonymous:devframe:auth` / `:exchange` handshake
// and the `devframe:auth:revoke` self-revoke. The resolver gate and the
// one-time-code banner are wired up by `initHub`'s `auth` option (same
// handler) in `createDevToolsHub`. Skipped entirely when the client-auth
// gate is disabled — leaving `anonymous:devframe:auth` unregistered lets
// devframe's `auth: false` auto-trust shim (armed by `createDevToolsHub`
// passing `auth: false` to `initHub`) register its own noop handler and
// mark sessions trusted, instead of the interactive handler winning the
// race and leaving every session stuck untrusted.
// handler) in `createDevToolsHub`. This also covers implicit build mode,
// where the same handler additionally trusts the per-process capability
// token (its banner suppressed) — see `getAuthHandler` /
// `isBuildCapabilityAuth`. Skipped only when the gate is fully disabled
// (`isClientAuthDisabled`) — leaving `anonymous:devframe:auth` unregistered
// lets devframe's `auth: false` auto-trust shim (armed by `createDevToolsHub`
// passing `auth: false` to `initHub`) register its own noop handler and mark
// sessions trusted, instead of the interactive handler winning the race and
// leaving every session stuck untrusted.
if (!isClientAuthDisabled(context)) {
for (const fn of getAuthHandler(context).rpcFunctions)
rpcHost.register(fn)
Expand Down
Loading
Loading