Skip to content

Commit

Permalink
fix: introduce local auth for running commands (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Jun 5, 2023
1 parent d68c03f commit 306c6a5
Show file tree
Hide file tree
Showing 35 changed files with 924 additions and 231 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@
"nuxt": "^3.5.2",
"pathe": "^1.1.1",
"simple-git-hooks": "^2.8.1",
"taze": "^0.10.2",
"tiged": "^2.12.5",
"typescript": "5.0.4",
"typescript": "^5.1.3",
"ua-parser-js": "^1.0.35",
"unocss": "^0.53.0",
"vite-hot-client": "^0.2.1",
"vue-tsc": "^1.6.5"
Expand Down
8 changes: 8 additions & 0 deletions packages/devtools-kit/src/_types/custom-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export interface ModuleCustomTab {
* Advanced options. You don't usually need this.
*/
extraTabVNode?: VNode

/**
* Require local authentication to access the tab
* It's highly recommended to enable this if the tab have sensitive information or have access to the OS
*
* @default false
*/
requireAuth?: boolean
}

export interface ModuleLaunchView {
Expand Down
10 changes: 10 additions & 0 deletions packages/devtools-kit/src/_types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export interface ModuleOptions {
* @default true
*/
viteInspect?: boolean

/**
* Disable dev time authorization check.
*
* **NOT RECOMMENDED**, only use this if you know what you are doing.
*
* @see https://github.com/nuxt/devtools/pull/257
* @default false
*/
disableAuthorization?: boolean
}

export interface ModuleGlobalOptions {
Expand Down
21 changes: 12 additions & 9 deletions packages/devtools-kit/src/_types/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Component, NuxtApp, NuxtLayout, NuxtOptions, NuxtPage } from 'nuxt/schema'
import type { StorageMounts } from 'nitropack'
import type { StorageValue } from 'unstorage'
import type { NuxtDevToolsUIOptions } from './options'
import type { ModuleOptions, NuxtDevToolsUIOptions } from './options'
import type { ModuleCustomTab } from './custom-tabs'
import type { AssetInfo, AutoImportsWithMetadata, ComponentRelationship, HookInfo, ImageMeta, NpmCommandOptions, NpmCommandType, PackageManagerName, PackageUpdateInfo, ServerRouteInfo } from './integrations'
import type { TerminalAction, TerminalInfo } from './terminals'
Expand All @@ -12,6 +12,7 @@ import type { InstallModuleReturn } from './server-ctx'
export interface ServerFunctions {
// Static RPCs (can be provide on production build in the future)
getServerConfig(): NuxtOptions
getModuleOptions(): ModuleOptions
getComponents(): Component[]
getComponentsRelationships(): Promise<ComponentRelationship[]>
getAutoImports(): AutoImportsWithMetadata
Expand All @@ -31,11 +32,11 @@ export interface ServerFunctions {
checkForUpdateFor(name: string): Promise<PackageUpdateInfo | undefined>
getPackageManager(): Promise<PackageManagerName>
getNpmCommand(command: NpmCommandType, packageName: string, options?: NpmCommandOptions): Promise<string[] | undefined>
runNpmCommand(command: NpmCommandType, packageName: string, options?: NpmCommandOptions): Promise<{ processId: string } | undefined>
runNpmCommand(token: string, command: NpmCommandType, packageName: string, options?: NpmCommandOptions): Promise<{ processId: string } | undefined>

// Terminal
getTerminals(): TerminalInfo[]
getTerminalDetail(id: string): TerminalInfo | undefined
getTerminalDetail(token: string, id: string): TerminalInfo | undefined
runTerminalAction(id: string, action: TerminalAction): Promise<boolean>

// Storage
Expand All @@ -48,21 +49,23 @@ export interface ServerFunctions {
// Analyze
getAnalyzeBuildInfo(): Promise<AnalyzeBuildsInfo>
generateAnalyzeBuildName(): Promise<string>
startAnalyzeBuild(name: string): Promise<string>
clearAnalyzeBuilds(names?: string[]): Promise<void>
startAnalyzeBuild(token: string, name: string): Promise<string>
clearAnalyzeBuilds(token: string, names?: string[]): Promise<void>

// Queries
getImageMeta(filepath: string): Promise<ImageMeta | undefined>
getTextAssetContent(filepath: string, limit?: number): Promise<string | undefined>
writeStaticAssets(file: { name: string; data: string }[], path: string): Promise<string[]>
writeStaticAssets(token: string, file: { name: string; data: string }[], path: string): Promise<string[]>

// Actions
customTabAction(name: string, action: number): Promise<boolean>
runWizard<T extends WizardActions>(name: T, ...args: GetWizardArgs<T>): Promise<void>
runWizard<T extends WizardActions>(token: string, name: T, ...args: GetWizardArgs<T>): Promise<void>
openInEditor(filepath: string): Promise<boolean>
requestForAuth(info?: string): Promise<void>
verifyAuthToken(token: string): Promise<boolean>
restartNuxt(hard?: boolean): Promise<void>
installNuxtModule(name: string, dry?: boolean): Promise<InstallModuleReturn>
uninstallNuxtModule(name: string, dry?: boolean): Promise<InstallModuleReturn>
installNuxtModule(token: string, name: string, dry?: boolean): Promise<InstallModuleReturn>
uninstallNuxtModule(token: string, name: string, dry?: boolean): Promise<InstallModuleReturn>
}

export interface ClientFunctions {
Expand Down
5 changes: 5 additions & 0 deletions packages/devtools-kit/src/_types/server-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface NuxtDevtoolsServerContext {
*/
refresh: (event: keyof ServerFunctions) => void

/**
* Ensure dev auth token is valid, throw if not
*/
ensureDevAuthToken: (token: string) => Promise<void>

extendServerRpc: <ClientFunctions = {}, ServerFunctions = {}>(name: string, functions: ServerFunctions) => BirpcGroup<ClientFunctions, ServerFunctions>
}

Expand Down
1 change: 1 addition & 0 deletions packages/devtools/client/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ onMounted(() => {
<NuxtPage />
</NuxtLayout>
<CommandPalette />
<AuthConfirmDialog />
</div>
<DisconnectIndicator />
<RestartDialogs />
Expand Down
30 changes: 30 additions & 0 deletions packages/devtools/client/components/AuthConfirmDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { AuthComfirm } from '~/composables/dialog'
</script>

<template>
<AuthComfirm v-slot="{ resolve }">
<NDialog :model-value="true" @close="resolve(false)">
<div p4 flex="~ col gap-2">
<h3 class="mb2 text-lg font-medium leading-6" flex="~ items-center gap-1" text-orange>
<span class="i-carbon-information-square" /> Permissions required
</h3>
<p>
This operation requires permissions for running command and access files from the browser.
</p>
<p>
A request is sent to the server.<br>
Please check your terminal for the instructions and then come back.
</p>
<div flex="~ gap-3" mt2 justify-end>
<NButton @click="resolve(false)">
Cancel
</NButton>
<NButton disabled icon="i-carbon-time">
Waiting for authorization...
</NButton>
</div>
</div>
</NDialog>
</AuthComfirm>
</template>
6 changes: 5 additions & 1 deletion packages/devtools/client/components/BuildAnalyzeDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ function formatFileSize(bytes: number) {
function formatDuration(build: AnalyzeBuildMeta) {
return `${((build.endTime - build.startTime) / 1000).toFixed(1)}s`
}
async function clear(name: string) {
return rpc.clearAnalyzeBuilds(await ensureDevAuthToken(), [name])
}
</script>

<template>
Expand Down Expand Up @@ -111,7 +115,7 @@ function formatDuration(build: AnalyzeBuildMeta) {
</div>
</div>
<div flex-auto />
<NButton n="rose" icon="carbon-delete" @click="rpc.clearAnalyzeBuilds([current.name])">
<NButton n="rose" icon="carbon-delete" @click="clear(current.name)">
Delete this report
</NButton>
</div>
Expand Down
35 changes: 26 additions & 9 deletions packages/devtools/client/components/DropZone.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const props = defineProps({
})
const emit = defineEmits(['uploaded'])
const showNotification = useNotification()
const visible = ref(false)
const lastTarget = ref()
Expand Down Expand Up @@ -51,12 +50,23 @@ function setFiles(data: FileList | null) {
existingFileNames.push(newFilename)
}
else {
if (file.type === '')
showNotification('Folders are not supported yet', 'carbon:face-dissatisfied')
else if (uploadTypes.some(type => file.type.includes(type)))
if (file.type === '') {
showNotification({
message: 'Folders are not supported yet',
icon: 'carbon:face-dissatisfied',
classes: 'text-orange',
})
}
else if (uploadTypes.some(type => file.type.includes(type))) {
newFiles.push(file)
else
showNotification(`"${file.type}" file type is not allowed`, 'carbon:face-dizzy')
}
else {
showNotification({
message: `"${file.type}" file type is not allowed`,
icon: 'carbon:face-dissatisfied',
classes: 'text-orange',
})
}
}
}
files.value = [...files.value, ...newFiles]
Expand All @@ -81,14 +91,21 @@ async function uploadFiles() {
data,
})
}
await rpc.writeStaticAssets([...readyFiles], props.folder).then(() => {
await rpc.writeStaticAssets(await ensureDevAuthToken(), [...readyFiles], props.folder).then(() => {
emit('uploaded')
close()
showNotification('Files uploaded successfully!', 'carbon:face-cool')
showNotification({
message: 'Files uploaded successfully!',
icon: 'i-carbon:checkmark',
})
}).catch((error) => {
console.error(error)
close()
showNotification('Upload failed!', 'carbon:face-dizzy')
showNotification({
message: `Error uploading files: ${error}`,
icon: 'i-carbon-warning',
classes: 'text-red',
})
})
visible.value = false
}
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools/client/components/ModuleItemInstall.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const isUninstallable = computed(() => installedInfo.value && installedInfo.valu
async function useModuleAction(item: ModuleStaticInfo, type: ModuleActionType) {
const method = type === 'install' ? rpc.installNuxtModule : rpc.uninstallNuxtModule
const result = await method(item.npm, true)
const result = await method(await ensureDevAuthToken(), item.npm, true)
if (!result.commands)
return
Expand All @@ -30,7 +30,7 @@ async function useModuleAction(item: ModuleStaticInfo, type: ModuleActionType) {
emit('start')
await method(item.npm, false)
await method(await ensureDevAuthToken(), item.npm, false)
}
const anyObj = {} as any
Expand Down
15 changes: 8 additions & 7 deletions packages/devtools/client/components/Notification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,30 @@
const show = ref(false)
const icon = ref<string | undefined>()
const text = ref<string | undefined>()
const classes = ref<string | undefined>()
provideNotification((_text, _icon, duration = 1500) => {
text.value = _text
icon.value = _icon
provideNotificationFn((data) => {
text.value = data.message
icon.value = data.icon
classes.value = data.classes ?? 'text-primary'
show.value = true
setTimeout(() => {
show.value = false
}, duration)
}, data.duration ?? 1500)
})
</script>

<template>
<div

fixed left-0 right-0 top-0 z-50 text-center
:class="show ? '' : 'pointer-events-none overflow-hidden'"
>
<div
border="~ base"
flex="~ inline gap2"
m-3 inline-block items-center rounded px-4 py-1 text-primary transition-all duration-300 bg-base
m-3 inline-block items-center rounded px-4 py-1 transition-all duration-300 bg-base
:style="show ? {} : { transform: 'translateY(-300%)' }"
:class="show ? 'shadow' : 'shadow-none'"
:class="[show ? 'shadow' : 'shadow-none', classes]"
>
<div v-if="icon" :class="icon" />
<div>{{ text }}</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/client/components/TerminalView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ onMounted(async () => {
fitAddon.fit()
})
info.value = await rpc.getTerminalDetail(props.id)
info.value = await rpc.getTerminalDetail(await ensureDevAuthToken(), props.id)
if (info.value?.buffer)
term.write(info.value.buffer)
Expand Down
22 changes: 0 additions & 22 deletions packages/devtools/client/composables/context.ts

This file was deleted.

65 changes: 65 additions & 0 deletions packages/devtools/client/composables/dev-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { until } from '@vueuse/core'
import { UAParser } from 'ua-parser-js'

export const devAuthToken = ref<string | null>(localStorage.getItem('__nuxt_dev_token__'))

export const isDevAuthed = ref(false)

const bc = new BroadcastChannel('__nuxt_dev_token__')

bc.addEventListener('message', (e) => {
if (e.data.event === 'new-token') {
const token = e.data.data
rpc.verifyAuthToken(token)
.then((result) => {
devAuthToken.value = result ? token : null
isDevAuthed.value = result
})
}
})

export async function ensureDevAuthToken() {
if (isDevAuthed.value)
return devAuthToken.value!

if (!devAuthToken.value) {
const info = new UAParser(navigator.userAgent).getResult()
const desc = [
info.browser.name,
info.browser.version,
'|',
info.os.name,
info.os.version,
info.device.type,
].filter(i => i).join(' ')
rpc.requestForAuth(desc)

const result = await Promise.race([
AuthComfirm.start(),
until(devAuthToken.value).toBeTruthy(),
])

if (result === false) {
// @unocss-include
showNotification({
message: 'Action canceled',
icon: 'carbon-close',
classes: 'text-orange',
})
throw new Error('User canceled auth')
}
}

isDevAuthed.value = await rpc.verifyAuthToken(devAuthToken.value!)
if (!isDevAuthed.value) {
devAuthToken.value = null
showNotification({
message: 'Invalid auth token, action canceled',
icon: 'i-carbon-warning-alt',
classes: 'text-red',
})
throw new Error('Invalid auth token')
}

return devAuthToken.value!
}

0 comments on commit 306c6a5

Please sign in to comment.