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
2 changes: 2 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ export const alias = {
'@vitejs/devtools-kit/constants': r('kit/src/constants.ts'),
'@vitejs/devtools-kit/utils/events': r('kit/src/utils/events.ts'),
'@vitejs/devtools-kit/utils/nanoid': r('kit/src/utils/nanoid.ts'),
'@vitejs/devtools-kit/utils/human-id': r('kit/src/utils/human-id.ts'),
'@vitejs/devtools-kit/utils/shared-state': r('kit/src/utils/shared-state.ts'),
'@vitejs/devtools-kit': r('kit/src/index.ts'),
'@vitejs/devtools-rolldown': r('rolldown/src/index.ts'),
'@vitejs/devtools-self-inspect': r('self-inspect/src/index.ts'),
'@vitejs/devtools/internal': r('core/src/internal.ts'),
'@vitejs/devtools/client/inject': r('core/src/client/inject/index.ts'),
'@vitejs/devtools/client/webcomponents': r('core/src/client/webcomponents/index.ts'),
'@vitejs/devtools': r('core/src/index.ts'),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"./client/webcomponents": "./dist/client/webcomponents.js",
"./config": "./dist/config.js",
"./dirs": "./dist/dirs.js",
"./internal": "./dist/internal.js",
"./package.json": "./package.json"
},
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -59,7 +60,7 @@
"birpc": "catalog:deps",
"cac": "catalog:deps",
"h3": "catalog:deps",
"immer": "catalog:deps",
"immer": "catalog:inlined",
"launch-editor": "catalog:deps",
"mlly": "catalog:deps",
"obug": "catalog:deps",
Expand All @@ -77,6 +78,7 @@
"@xterm/addon-fit": "catalog:frontend",
"@xterm/xterm": "catalog:frontend",
"dompurify": "catalog:frontend",
"human-id": "catalog:inlined",
"tsdown": "catalog:build",
"typescript": "catalog:devtools",
"unplugin-vue": "catalog:build",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/client/webcomponents/.generated/css.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ function onPointerDown(e: PointerEvent) {
const isRpcTrusted = ref(context.rpc.isTrusted)
context.rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
isRpcTrusted.value = isTrusted
if (isTrusted && context.docks.selected?.id === BUILTIN_ENTRY_CLIENT_AUTH_NOTICE.id)
if (isTrusted && context.docks.selected?.id === BUILTIN_ENTRY_CLIENT_AUTH_NOTICE.id) {
context.docks.switchEntry(null)
}
else if (!isTrusted) {
// On revocation: close current tab and panel
context.docks.switchEntry(null)
context.panel.store.open = false
}
})

const groupedEntries = computed(() => context.docks.groupedEntries)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { useEventListener } from '@vueuse/core'
import { onUnmounted } from 'vue'
import { onUnmounted, ref } from 'vue'
import { sharedStateToRef } from '../../state/docks'
import { closeDockPopup, useIsDockPopupOpen } from '../../state/popup'
import ToastOverlay from '../display/ToastOverlay.vue'
Expand All @@ -17,6 +17,12 @@ const props = defineProps<{
const isDockPopupOpen = useIsDockPopupOpen()
const settings = sharedStateToRef(props.context.docks.settings)

// Force float mode when unauthorized, regardless of store setting
const isRpcTrusted = ref(props.context.rpc.isTrusted)
props.context.rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
isRpcTrusted.value = isTrusted
})

// Close the dock when clicking outside of it
useEventListener(window, 'mousedown', (e: MouseEvent) => {
if (!settings.value.closeOnOutsideClick)
Expand Down Expand Up @@ -44,7 +50,7 @@ onUnmounted(() => {

<template>
<template v-if="!isDockPopupOpen">
<template v-if="context.panel.store.mode === 'edge'">
<template v-if="isRpcTrusted && context.panel.store.mode === 'edge'">
<DockEdge :context />
</template>
<template v-else>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const persistedDoms = markRaw(new PersistedDomViewsManager(viewsContainer))
const isRpcTrusted = ref(context.rpc.isTrusted)
context.rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
isRpcTrusted.value = isTrusted
if (!isTrusted) {
context.docks.switchEntry(null)
}
})

watch(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
<script setup lang="ts">
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { ref } from 'vue'
import VitePlus from '../icons/VitePlus.vue'

defineProps<{
const props = defineProps<{
context: DocksContext
}>()

const tokenInput = ref('')

function submitToken() {
const value = tokenInput.value.trim()
if (!value)
return
props.context.rpc.requestTrustWithToken(value)
}
</script>

<template>
<div class="w-full h-full flex flex-col items-center justify-center p20">
<div class="max-w-150 flex flex-col items-center justify-center gap-2">
<VitePlus class="w-20 h-20" />
<h1 class="text-2xl font-bold text-violet mb2">
Vite DevTools is Unauthorized
Vite DevTools needs Authorization
</h1>
<p class="op75">
Vite DevTools offers advanced features that can access your server, view your filesystem, and execute commands.
Expand All @@ -23,6 +33,24 @@ defineProps<{
<p class="font-bold bg-green:5 p1 px3 rounded mt8 text-green">
Check your terminal for the authorization prompt and come back.
</p>
<div class="mt6 op50">
or
</div>
<form class="mt2 flex items-center gap-2" @submit.prevent="submitToken">
<input
v-model="tokenInput"
type="text"
placeholder="Enter auth token"
class="px3 py1.5 rounded border border-base bg-transparent text-sm outline-none focus:border-violet"
>
<button
type="submit"
class="px3 py1.5 rounded bg-violet text-white text-sm hover:op80 disabled:op40"
:disabled="!tokenInput.trim()"
>
Authorize
</button>
</form>
</div>
</div>
</template>
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { createDevToolsContext } from './node/context'
export type { DevToolsInternalContext, InternalAnonymousAuthStorage } from './node/context-internal'
export { DevTools } from './node/plugins'
export { createDevToolsMiddleware } from './node/server'
2 changes: 2 additions & 0 deletions packages/core/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getInternalContext } from './node/context-internal'
export type { DevToolsInternalContext, InternalAnonymousAuthStorage } from './node/context-internal'
43 changes: 43 additions & 0 deletions packages/core/src/node/auth-revoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { DevToolsNodeContext } from '@vitejs/devtools-kit'
import type { SharedState } from '@vitejs/devtools-kit/utils/shared-state'
import type { InternalAnonymousAuthStorage } from './context-internal'
import type { RpcFunctionsHost } from './host-functions'

/**
* Revoke an auth token: remove from storage and notify all connected clients
* using this token that they are no longer trusted.
*/
export async function revokeAuthToken(
context: DevToolsNodeContext,
storage: SharedState<InternalAnonymousAuthStorage>,
token: string,
): Promise<void> {
// Remove from persistent storage
storage.mutate((state) => {
delete state.trusted[token]
})

const rpcHost = context.rpc as unknown as RpcFunctionsHost
if (!rpcHost._rpcGroup)
return

// Collect affected session IDs before modifying meta
const affectedSessionIds = new Set<string>()
for (const client of rpcHost._rpcGroup.clients) {
if (client.$meta.clientAuthToken === token) {
affectedSessionIds.add(client.$meta.id)
client.$meta.isTrusted = false
client.$meta.clientAuthToken = undefined!
}
}

if (affectedSessionIds.size === 0)
return

// Notify affected clients
await rpcHost.broadcast({
method: 'devtoolskit:internal:auth:revoked',
args: [],
filter: client => affectedSessionIds.has(client.$meta.id),
})
}
87 changes: 87 additions & 0 deletions packages/core/src/node/auth-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { DevToolsNodeRpcSession } from '@vitejs/devtools-kit'
import type { SharedState } from '@vitejs/devtools-kit/utils/shared-state'
import type { InternalAnonymousAuthStorage } from './context-internal'
import { humanId } from '@vitejs/devtools-kit/utils/human-id'

export interface PendingAuthRequest {
clientAuthToken: string
session: DevToolsNodeRpcSession
ua: string
origin: string
resolve: (result: { isTrusted: boolean }) => void
abortController: AbortController
timeout: ReturnType<typeof setTimeout>
}

let pendingAuth: PendingAuthRequest | null = null
let tempAuthToken: string = generateTempId()

function generateTempId(): string {
return humanId({ separator: '-', capitalize: false })
}

export function getTempAuthToken(): string {
return tempAuthToken
}

export function refreshTempAuthToken(): string {
tempAuthToken = generateTempId()
return tempAuthToken
}

export function getPendingAuth(): PendingAuthRequest | null {
return pendingAuth
}

export function setPendingAuth(request: PendingAuthRequest | null): void {
pendingAuth = request
}

/**
* Abort and clean up any existing pending auth request.
*/
export function abortPendingAuth(): void {
if (pendingAuth) {
pendingAuth.abortController.abort()
clearTimeout(pendingAuth.timeout)
pendingAuth = null
}
}

/**
* Consume the temp auth ID: verify it matches, trust the pending client, and clean up.
* Returns the client's authToken if successful, null otherwise.
*/
export function consumeTempAuthToken(
id: string,
storage: SharedState<InternalAnonymousAuthStorage>,
): string | null {
if (id !== tempAuthToken || !pendingAuth) {
return null
}

const { clientAuthToken, session, ua, origin, resolve } = pendingAuth

// Trust the pending client
storage.mutate((state) => {
state.trusted[clientAuthToken] = {
authToken: clientAuthToken,
ua,
origin,
timestamp: Date.now(),
}
})
session.meta.clientAuthToken = clientAuthToken
session.meta.isTrusted = true

// Resolve the pending auth RPC call
resolve({ isTrusted: true })

// Abort terminal prompt and clean up
abortPendingAuth()

// Generate a new temp ID for next use
refreshTempAuthToken()

return clientAuthToken
}
8 changes: 8 additions & 0 deletions packages/core/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface DevToolsConfig extends Partial<StartOptions> {
* @default true
*/
clientAuth?: boolean
/**
* Pre-configured auth tokens that are automatically trusted.
*
* Clients connecting with an auth token matching one of these
* will be auto-approved without a terminal prompt.
*/
clientAuthTokens?: string[]
}

export interface ResolvedDevToolsConfig {
Expand All @@ -29,6 +36,7 @@ export function normalizeDevToolsConfig(
config: {
...(isObject(config) ? config : {}),
clientAuth: isObject(config) ? (config.clientAuth ?? true) : true,
clientAuthTokens: isObject(config) ? (config.clientAuthTokens ?? []) : [],
host: isObject(config) ? (config.host ?? host) : host,
},
}
Expand Down
24 changes: 16 additions & 8 deletions packages/core/src/node/context-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,44 @@ import type { DevToolsNodeContext } from '@vitejs/devtools-kit'
import type { SharedState } from '@vitejs/devtools-kit/utils/shared-state'
import { homedir } from 'node:os'
import { join } from 'pathe'
import { revokeAuthToken } from './auth-revoke'
import { createStorage } from './storage'

export interface InternalAnonymousAuthStorage {
trusted: Record<string, {
authId: string
authToken: string
ua: string
origin: string
timestamp: number
}>
} | undefined>
}

export interface DevToolsInternalContext {
storage: {
auth: SharedState<InternalAnonymousAuthStorage>
}
/**
* Revoke an auth token: remove from storage and notify all connected clients
* using this token that they are no longer trusted.
*/
revokeAuthToken: (token: string) => Promise<void>
}

export const internalContextMap = new WeakMap<DevToolsNodeContext, DevToolsInternalContext>()

export function getInternalContext(context: DevToolsNodeContext): DevToolsInternalContext {
if (!internalContextMap.has(context)) {
const storage = createStorage<InternalAnonymousAuthStorage>({
filepath: join(homedir(), '.vite/devtools/auth.json'),
initialValue: {
trusted: {},
},
})
const internalContext: DevToolsInternalContext = {
storage: {
auth: createStorage<InternalAnonymousAuthStorage>({
filepath: join(homedir(), '.vite/devtools/auth.json'),
initialValue: {
trusted: {},
},
}),
auth: storage,
},
revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token),
}
internalContextMap.set(context, internalContext)
}
Expand Down
Loading
Loading