Compare commits

...

4 Commits

Author SHA1 Message Date
kishore
607bd8a309 fix: proxy registry API in local cloud dev
Route /customers and /releases to the Comfy registry host when the backend is loopback ingest; those paths are not implemented on ingest.

Stub /api/i18n for cloud dev so bootstrap does not hard-fail against OSS-only routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 00:31:19 -07:00
kishore
101f054395 fix: disable Mixpanel in local cloud dev
Avoid sending local OAuth browser-test telemetry to the cloud Mixpanel proxy when the cloud frontend is pointed at a loopback backend.
2026-05-08 00:27:26 -07:00
kishore
cb042bee24 fix: use local API base in cloud dev
Keep local cloud development on the Vite proxy path so user-scoped customer requests do not leak to staging.
2026-05-08 00:02:34 -07:00
kishore
e3d2ae3cef feat: add OAuth frontend consent flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 23:33:12 -07:00
25 changed files with 1067 additions and 35 deletions

1
global.d.ts vendored
View File

@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
declare const __DEV_SERVER_COMFYUI_URL__: string
interface ImpactQueueFunction {
(...args: unknown[]): void

View File

@@ -11,6 +11,7 @@ declare global {
const __ALGOLIA_APP_ID__: string
const __ALGOLIA_API_KEY__: string
const __USE_PROD_CONFIG__: boolean
const __DEV_SERVER_COMFYUI_URL__: string
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
const __IS_NIGHTLY__: boolean
}
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
__ALGOLIA_APP_ID__: string
__ALGOLIA_API_KEY__: string
__USE_PROD_CONFIG__: boolean
__DEV_SERVER_COMFYUI_URL__: string
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
__IS_NIGHTLY__: boolean
window?: Record<string, unknown>
@@ -37,6 +39,7 @@ globalWithDefines.__SENTRY_DSN__ = ''
globalWithDefines.__ALGOLIA_APP_ID__ = ''
globalWithDefines.__ALGOLIA_API_KEY__ = ''
globalWithDefines.__USE_PROD_CONFIG__ = false
globalWithDefines.__DEV_SERVER_COMFYUI_URL__ = ''
globalWithDefines.__DISTRIBUTION__ = 'localhost'
globalWithDefines.__IS_NIGHTLY__ = false

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { getComfyApiBaseUrlForEnvironment } from '@/config/comfyApi'
describe('comfy api config', () => {
it('uses same-origin API calls for cloud local development', () => {
expect(
getComfyApiBaseUrlForEnvironment({
isCloudDistribution: true,
isDev: true,
devServerComfyUIUrl: 'http://127.0.0.1:8188',
useProdConfig: false
})
).toBe('')
})
it('keeps staging API for non-local staging builds', () => {
expect(
getComfyApiBaseUrlForEnvironment({
isCloudDistribution: true,
isDev: false,
useProdConfig: false
})
).toBe('https://stagingapi.comfy.org')
})
})

View File

@@ -10,34 +10,68 @@ const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
? PROD_API_BASE_URL
: STAGING_API_BASE_URL
type ComfyApiEnvironment = {
isCloudDistribution: boolean
isDev: boolean
devServerComfyUIUrl?: string
useProdConfig: boolean
}
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? PROD_PLATFORM_BASE_URL
: STAGING_PLATFORM_BASE_URL
const localOriginPattern =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
function buildTimeApiBaseUrl(useProdConfig: boolean): string {
return useProdConfig ? PROD_API_BASE_URL : STAGING_API_BASE_URL
}
function buildTimePlatformBaseUrl(useProdConfig: boolean): string {
return useProdConfig ? PROD_PLATFORM_BASE_URL : STAGING_PLATFORM_BASE_URL
}
function isLocalDevServer(url?: string): boolean {
return url ? localOriginPattern.test(url) : false
}
export function getComfyApiBaseUrlForEnvironment({
isCloudDistribution,
isDev,
devServerComfyUIUrl,
useProdConfig
}: ComfyApiEnvironment): string {
const buildTimeApiBaseUrlValue = buildTimeApiBaseUrl(useProdConfig)
if (!isCloudDistribution) {
return buildTimeApiBaseUrlValue
}
if (isDev && isLocalDevServer(devServerComfyUIUrl)) {
return ''
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
buildTimeApiBaseUrlValue
)
}
export function getComfyApiBaseUrl(): string {
return getComfyApiBaseUrlForEnvironment({
isCloudDistribution: isCloud,
isDev: import.meta.env.DEV,
devServerComfyUIUrl: __DEV_SERVER_COMFYUI_URL__,
useProdConfig: __USE_PROD_CONFIG__
})
}
export function getComfyPlatformBaseUrl(): string {
const buildTimePlatformBaseUrlValue =
buildTimePlatformBaseUrl(__USE_PROD_CONFIG__)
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
return buildTimePlatformBaseUrlValue
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
BUILD_TIME_PLATFORM_BASE_URL
buildTimePlatformBaseUrlValue
)
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
getFirebaseConfigForEnvironment,
getFirebaseAuthEmulatorUrl
} from '@/config/firebase'
describe('firebase config', () => {
it('uses the explicit local project id when the auth emulator is enabled', () => {
const config = getFirebaseConfigForEnvironment({
isCloudBuild: false,
useProdConfig: false,
authEmulatorHost: '127.0.0.1:9099',
localProjectId: 'demo-cloud'
})
expect(config.projectId).toBe('demo-cloud')
expect(config.authDomain).toBe('demo-cloud.firebaseapp.com')
})
it('fails fast when the auth emulator is enabled without a local project id', () => {
expect(() =>
getFirebaseConfigForEnvironment({
isCloudBuild: false,
useProdConfig: false,
authEmulatorHost: '127.0.0.1:9099'
})
).toThrow('VITE_FIREBASE_PROJECT_ID is required')
})
it('does not connect to the emulator without the explicit host flag', () => {
expect(getFirebaseAuthEmulatorUrl(undefined)).toBeNull()
expect(getFirebaseAuthEmulatorUrl('127.0.0.1:9099')).toBe(
'http://127.0.0.1:9099'
)
})
})

View File

@@ -25,7 +25,54 @@ const PROD_CONFIG: FirebaseOptions = {
measurementId: 'G-3ZBD3MBTG4'
}
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
type FirebaseEnvironment = {
isCloudBuild: boolean
useProdConfig: boolean
authEmulatorHost?: string
localProjectId?: string
}
function buildLocalEmulatorConfig(
buildTimeConfig: FirebaseOptions,
localProjectId: string | undefined
): FirebaseOptions {
if (!localProjectId) {
throw new Error(
'VITE_FIREBASE_PROJECT_ID is required when VITE_FIREBASE_AUTH_EMULATOR_HOST is set'
)
}
return {
...buildTimeConfig,
projectId: localProjectId,
authDomain: `${localProjectId}.firebaseapp.com`
}
}
export function getFirebaseAuthEmulatorUrl(
host: string | undefined
): string | null {
return host ? `http://${host}` : null
}
export function getFirebaseConfigForEnvironment({
isCloudBuild,
useProdConfig,
authEmulatorHost,
localProjectId
}: FirebaseEnvironment): FirebaseOptions {
const buildTimeConfig = useProdConfig ? PROD_CONFIG : DEV_CONFIG
if (authEmulatorHost) {
return buildLocalEmulatorConfig(buildTimeConfig, localProjectId)
}
if (!isCloudBuild) {
return buildTimeConfig
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? buildTimeConfig
}
/**
* Returns the Firebase configuration for the current environment.
@@ -33,10 +80,10 @@ const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
return getFirebaseConfigForEnvironment({
isCloudBuild: isCloud,
useProdConfig: __USE_PROD_CONFIG__,
authEmulatorHost: import.meta.env.VITE_FIREBASE_AUTH_EMULATOR_HOST,
localProjectId: import.meta.env.VITE_FIREBASE_PROJECT_ID
})
}

View File

@@ -2130,6 +2130,19 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"oauth": {
"consent": {
"allow": "Allow",
"deny": "Deny",
"genericError": "OAuth request failed. Please restart from the client app.",
"loading": "Loading OAuth request...",
"missingRequest": "This OAuth request is missing. Please restart from the client app.",
"noWorkspaces": "No eligible workspaces are available for this request.",
"resourceLabel": "Authorize this app",
"scopesTitle": "Requested access",
"workspaceTitle": "Choose workspace"
}
},
"auth": {
"apiKey": {
"title": "API Key",

View File

@@ -2,6 +2,7 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import { connectAuthEmulator, getAuth } from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -11,7 +12,10 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import {
getFirebaseAuthEmulatorUrl,
getFirebaseConfig
} from '@/config/firebase'
import {
configValueOrDefault,
remoteConfig
@@ -48,6 +52,14 @@ const ComfyUIPreset = definePreset(Aura, {
})
const firebaseApp = initializeApp(getFirebaseConfig())
const firebaseAuthEmulatorUrl = getFirebaseAuthEmulatorUrl(
import.meta.env.VITE_FIREBASE_AUTH_EMULATOR_HOST
)
if (firebaseAuthEmulatorUrl) {
connectAuthEmulator(getAuth(firebaseApp), firebaseAuthEmulatorUrl, {
disableWarnings: true
})
}
const app = createApp(App)
const pinia = createPinia()

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetIdToken = vi.fn()
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
teamWorkspacesEnabled: true
}
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
getAuthHeader: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
describe('useSessionCookie', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
mockGetIdToken.mockReset()
globalThis.fetch = vi.fn()
})
it('createSessionOrThrow posts the Firebase token and awaits success', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(null, { status: 204 })
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await useSessionCookie().createSessionOrThrow()
expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/session', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
}
})
})
it('createSessionOrThrow fails fast without a Firebase token', async () => {
mockGetIdToken.mockResolvedValue(null)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'No Firebase token available for session creation'
)
expect(globalThis.fetch).not.toHaveBeenCalled()
})
it('createSessionOrThrow fails fast on non-success responses', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ message: 'session denied' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'session denied'
)
})
})

View File

@@ -1,4 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
@@ -8,6 +9,35 @@ import { useAuthStore } from '@/stores/authStore'
* Creates and deletes session cookies on the ComfyUI server.
*/
export const useSessionCookie = () => {
const createSessionWithHeader = async (
authHeader: Record<string, string>
): Promise<Response> => {
return await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
}
const readSessionError = async (response: Response): Promise<string> => {
const errorData = await response.json().catch(() => ({}))
return errorData.message || response.statusText
}
const getFirebaseSessionHeaderOrThrow = async (): Promise<
Record<string, string>
> => {
const firebaseToken = await useAuthStore().getIdToken()
if (!firebaseToken) {
throw new Error('No Firebase token available for session creation')
}
return { Authorization: `Bearer ${firebaseToken}` }
}
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
@@ -47,20 +77,12 @@ export const useSessionCookie = () => {
authHeader = header
}
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
const response = await createSessionWithHeader(authHeader)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to create session cookie:',
errorData.message || response.statusText
await readSessionError(response)
)
}
} catch (error) {
@@ -68,11 +90,22 @@ export const useSessionCookie = () => {
}
}
const createSessionOrThrow = async (): Promise<void> => {
if (!isCloud) return
const authHeader = await getFirebaseSessionHeaderOrThrow()
const response = await createSessionWithHeader(authHeader)
if (!response.ok) {
throw new Error(await readSessionError(response))
}
}
/**
* Deletes the session cookie.
* Called on logout.
*/
const deleteSession = async (): Promise<void> => {
clearOAuthRequestId()
if (!isCloud) return
try {
@@ -95,6 +128,7 @@ export const useSessionCookie = () => {
return {
createSession,
createSessionOrThrow,
deleteSession
}
}

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const previewChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'preview-csrf-token',
client_display_name: 'Cursor',
resource_display_name: 'ComfyUI MCP',
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Team Workspace',
type: 'team',
role: 'member'
}
]
}
const meta: Meta<typeof OAuthConsentView> = {
title: 'Cloud/OAuth/Consent',
component: OAuthConsentView
}
export default meta
type Story = StoryObj<typeof meta>
export const AllMcpScopes: Story = {
args: {
initialChallenge: previewChallenge,
submitDecision: async () => {}
}
}

View File

@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
oauth: {
consent: {
allow: 'Allow',
deny: 'Deny',
genericError: 'OAuth request failed.',
loading: 'Loading OAuth request...',
missingRequest: 'This OAuth request is missing.',
noWorkspaces: 'No eligible workspaces are available.',
resourceLabel: 'Authorize this app',
scopesTitle: 'Requested access',
workspaceTitle: 'Choose workspace'
}
}
}
}
})
const challenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Cursor',
resource_display_name: 'ComfyUI MCP',
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Team',
type: 'team',
role: 'member'
}
]
}
const renderConsent = (
overrides: Partial<OAuthConsentChallenge> = {},
submitDecision = vi.fn()
) =>
render(OAuthConsentView, {
global: { plugins: [i18n] },
props: {
initialChallenge: { ...challenge, ...overrides },
submitDecision
}
})
describe('OAuthConsentView', () => {
it('renders client, resource, and scopes from the challenge', () => {
renderConsent()
expect(screen.getByText('Cursor')).toBeVisible()
expect(screen.getByText('ComfyUI MCP')).toBeVisible()
expect(screen.getByText('mcp:tools:read')).toBeVisible()
expect(screen.getByText('mcp:tools:call')).toBeVisible()
expect(screen.getByText('mcp:unknown:test')).toBeVisible()
})
it('requires workspace selection when multiple workspaces are available', async () => {
const user = userEvent.setup()
const submitDecision = vi.fn()
renderConsent({}, submitDecision)
const allow = screen.getByRole('button', { name: 'Allow' })
expect(allow).toBeDisabled()
await user.click(screen.getByLabelText(/Team/))
expect(allow).toBeEnabled()
await user.click(allow)
expect(submitDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'team-workspace'
})
})
it('renders a single workspace read-only without auto-submitting', async () => {
const user = userEvent.setup()
const submitDecision = vi.fn()
renderConsent(
{
workspaces: [challenge.workspaces[0]]
},
submitDecision
)
expect(screen.getByText('Personal')).toBeVisible()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
expect(submitDecision).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Allow' }))
expect(submitDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace'
})
})
it('submits deny with the selected workspace', async () => {
const user = userEvent.setup()
const submitDecision = vi.fn()
renderConsent({}, submitDecision)
await user.click(screen.getByLabelText(/Team/))
await user.click(screen.getByRole('button', { name: 'Deny' }))
expect(submitDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'deny',
workspaceId: 'team-workspace'
})
})
})

View File

@@ -0,0 +1,202 @@
<template>
<main class="mx-auto flex max-w-2xl flex-col gap-6 p-8">
<section v-if="challenge" class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted">
{{ t('oauth.consent.resourceLabel') }}
</p>
<h1 class="m-0 text-2xl font-semibold">
{{ challenge.client_display_name }}
</h1>
<p v-if="challenge.resource_display_name" class="m-0 text-muted">
{{ challenge.resource_display_name }}
</p>
</div>
<section class="rounded-lg bg-(--p-content-background) p-4">
<h2 class="mt-0 text-base font-semibold">
{{ t('oauth.consent.scopesTitle') }}
</h2>
<ul class="mb-0 flex flex-col gap-2 pl-5">
<li v-for="scope in challenge.scopes" :key="scope">
{{ labelForScope(scope) }}
</li>
</ul>
</section>
<section class="rounded-lg bg-(--p-content-background) p-4">
<h2 class="mt-0 text-base font-semibold">
{{ t('oauth.consent.workspaceTitle') }}
</h2>
<p v-if="challenge.workspaces.length === 0" class="text-muted">
{{ t('oauth.consent.noWorkspaces') }}
</p>
<div v-else-if="challenge.workspaces.length === 1">
<WorkspaceSummary :workspace="challenge.workspaces[0]" />
</div>
<fieldset v-else class="m-0 flex flex-col gap-3 border-0 p-0">
<legend class="sr-only">
{{ t('oauth.consent.workspaceTitle') }}
</legend>
<label
v-for="workspace in challenge.workspaces"
:key="workspace.id"
class="flex cursor-pointer gap-3 rounded-md border border-solid border-muted p-3"
>
<input
v-model="selectedWorkspaceId"
type="radio"
name="workspace"
:value="workspace.id"
/>
<WorkspaceSummary :workspace />
</label>
</fieldset>
</section>
<p v-if="errorMessage" role="alert" class="m-0 text-red-500">
{{ errorMessage }}
</p>
<div class="flex gap-3">
<button
class="rounded-md bg-neutral-700 px-4 py-2 text-white disabled:opacity-50"
:disabled="isSubmitting || !canSubmit"
@click="submit('deny')"
>
{{ t('oauth.consent.deny') }}
</button>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
:disabled="isSubmitting || !canSubmit"
@click="submit('allow')"
>
{{ t('oauth.consent.allow') }}
</button>
</div>
</section>
<p v-else-if="errorMessage" role="alert" class="m-0 text-red-500">
{{ errorMessage }}
</p>
<p v-else class="m-0 text-muted">
{{ t('oauth.consent.loading') }}
</p>
</main>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import {
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type {
OAuthConsentChallenge,
OAuthConsentDecision,
OAuthWorkspace
} from '@/platform/cloud/oauth/oauthApi'
import {
clearOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
defineProps<{
initialChallenge?: OAuthConsentChallenge
submitDecision?: OAuthConsentDecision
}>()
const WorkspaceSummary = (props: { workspace: OAuthWorkspace }) =>
h('span', { class: 'flex flex-col gap-1' }, [
h('span', props.workspace.name),
h('span', { class: 'text-xs text-muted' }, [
props.workspace.type,
' · ',
props.workspace.role
])
])
const { t } = useI18n()
const route = useRoute()
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
const selectedWorkspaceId = ref(
initialChallenge?.workspaces.length === 1
? initialChallenge.workspaces[0].id
: ''
)
const errorMessage = ref('')
const isSubmitting = ref(false)
const selectedWorkspaceIsValid = computed(() => {
return Boolean(
challenge.value?.workspaces.some(
(workspace) => workspace.id === selectedWorkspaceId.value
)
)
})
const canSubmit = computed(() => selectedWorkspaceIsValid.value)
function labelForScope(scope: string): string {
return scope
}
function requestIdFromRoute(): string | null {
return typeof route.query.oauth_request_id === 'string'
? route.query.oauth_request_id
: getOAuthRequestId()
}
function initializeWorkspaceSelection(nextChallenge: OAuthConsentChallenge) {
selectedWorkspaceId.value =
nextChallenge.workspaces.length === 1 ? nextChallenge.workspaces[0].id : ''
}
async function loadChallenge() {
const oauthRequestId = requestIdFromRoute()
if (!oauthRequestId) {
errorMessage.value = t('oauth.consent.missingRequest')
return
}
try {
const nextChallenge = await fetchOAuthConsentChallenge(oauthRequestId)
challenge.value = nextChallenge
initializeWorkspaceSelection(nextChallenge)
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : t('oauth.consent.genericError')
}
}
async function submit(decision: 'allow' | 'deny') {
if (!challenge.value || !selectedWorkspaceIsValid.value) return
errorMessage.value = ''
isSubmitting.value = true
try {
await submitDecision({
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId: selectedWorkspaceId.value
})
clearOAuthRequestId()
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : t('oauth.consent.genericError')
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
if (!initialChallenge) {
void loadChallenge()
}
})
</script>

View File

@@ -0,0 +1,125 @@
export type OAuthWorkspace = {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}
export type OAuthConsentChallenge = {
oauth_request_id: string
csrf_token: string
client_display_name: string
resource_display_name?: string
scopes: string[]
workspaces: OAuthWorkspace[]
}
export type OAuthConsentDecisionParams = {
oauthRequestId: string
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
type OAuthDecisionResponse = {
redirect_url?: string
}
export class OAuthApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'OAuthApiError'
}
}
function getOAuthOrigin(): string {
return import.meta.env.VITE_CLOUD_INGEST_ORIGIN ?? ''
}
function oauthUrl(path: string): string {
const origin = getOAuthOrigin()
return origin ? new URL(path, origin).toString() : path
}
async function readErrorMessage(response: Response): Promise<string> {
const body = await response.json().catch(() => null)
return body?.message ?? response.statusText
}
function assertChallenge(
value: unknown
): asserts value is OAuthConsentChallenge {
if (typeof value !== 'object' || value === null) {
throw new Error('OAuth consent challenge is invalid')
}
const challenge = value as Partial<OAuthConsentChallenge>
if (
typeof challenge.oauth_request_id !== 'string' ||
typeof challenge.csrf_token !== 'string' ||
typeof challenge.client_display_name !== 'string' ||
!Array.isArray(challenge.scopes) ||
!Array.isArray(challenge.workspaces)
) {
throw new Error('OAuth consent challenge is invalid')
}
}
export async function fetchOAuthConsentChallenge(
oauthRequestId: string
): Promise<OAuthConsentChallenge> {
const response = await fetch(
oauthUrl(`/oauth/authorize?oauth_request_id=${oauthRequestId}`),
{
method: 'GET',
credentials: 'include'
}
)
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const challenge: unknown = await response.json()
assertChallenge(challenge)
return challenge
}
export async function submitOAuthConsentDecision({
oauthRequestId,
csrfToken,
decision,
workspaceId
}: OAuthConsentDecisionParams): Promise<void> {
const response = await fetch(oauthUrl('/oauth/authorize'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
oauth_request_id: oauthRequestId,
csrf_token: csrfToken,
decision,
workspace_id: workspaceId
})
})
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const body: OAuthDecisionResponse = await response.json()
if (!body.redirect_url) {
throw new Error('OAuth consent response did not include redirect_url')
}
globalThis.location.href = body.redirect_url
}

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
captureOAuthRequestId,
clearOAuthRequestId,
getOAuthRequestId,
hasOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
describe('oauthState', () => {
beforeEach(() => {
sessionStorage.clear()
clearOAuthRequestId()
})
it('captures a valid oauth_request_id only', () => {
captureOAuthRequestId({
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
client_id: 'must-not-be-stored'
})
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
expect(hasOAuthRequestId()).toBe(true)
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
)
})
it('ignores missing, repeated, and invalid request ids', () => {
captureOAuthRequestId({})
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
expect(getOAuthRequestId()).toBeNull()
})
it('hydrates from session storage and clears after completion', () => {
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
clearOAuthRequestId()
expect(getOAuthRequestId()).toBeNull()
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
})
})

View File

@@ -0,0 +1,38 @@
import type { LocationQuery } from 'vue-router'
const OAUTH_REQUEST_ID_STORAGE_KEY = 'Comfy.OAuthRequestId'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function readQueryString(value: LocationQuery[string]): string | null {
return typeof value === 'string' ? value : null
}
function isOAuthRequestId(value: string): boolean {
return UUID_PATTERN.test(value)
}
function readStoredOAuthRequestId(): string | null {
const value = sessionStorage.getItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return value && isOAuthRequestId(value) ? value : null
}
export function captureOAuthRequestId(query: LocationQuery): string | null {
const value = readQueryString(query.oauth_request_id)
if (!value || !isOAuthRequestId(value)) return null
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
return value
}
export function getOAuthRequestId(): string | null {
return readStoredOAuthRequestId()
}
export function hasOAuthRequestId(): boolean {
return getOAuthRequestId() !== null
}
export function clearOAuthRequestId(): void {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}

View File

@@ -116,6 +116,11 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
@@ -127,6 +132,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const sessionCookie = useSessionCookie()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
@@ -153,6 +159,17 @@ const onSuccess = async () => {
life: 2000
})
captureOAuthRequestId(route.query)
const oauthRequestId = getOAuthRequestId()
if (oauthRequestId) {
await sessionCookie.createSessionOrThrow()
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return
}
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)

View File

@@ -141,6 +141,11 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
@@ -154,6 +159,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const sessionCookie = useSessionCookie()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
@@ -179,6 +185,17 @@ const onSuccess = async () => {
life: 2000
})
captureOAuthRequestId(route.query)
const oauthRequestId = getOAuthRequestId()
if (oauthRequestId) {
await sessionCookie.createSessionOrThrow()
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return
}
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)

View File

@@ -1,5 +1,20 @@
import type { RouteRecordRaw } from 'vue-router'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: '/cloud',
@@ -12,6 +27,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
captureOAuthRequestId(to.query)
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } =
@@ -19,9 +35,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
// User is already logged in, redirect to user-check
// user-check will handle survey, or main page routing
return next({ name: 'cloud-user-check' })
return next(oauthConsentRedirect())
}
}
next()
@@ -33,13 +47,14 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
beforeEnter: async (to, _from, next) => {
captureOAuthRequestId(to.query)
if (!to.query.switchAccount) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next({ name: 'cloud-user-check' })
return next(oauthConsentRedirect())
}
}
next()
@@ -58,6 +73,11 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
},
{
path: 'oauth/consent',
name: 'cloud-oauth-consent',
component: () => import('@/platform/cloud/oauth/OAuthConsentView.vue')
},
{
path: 'user-check',
name: 'cloud-user-check',

View File

@@ -2,5 +2,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite',
SHARE: 'share',
CREATE_WORKSPACE: 'create_workspace'
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth'
} as const

View File

@@ -75,6 +75,7 @@ const waitForMixpanelInit = () =>
vi.waitFor(() => expect(mockMixpanel.init).toHaveBeenCalled())
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
type DefineGlobal = typeof globalThis & { __DEV_SERVER_COMFYUI_URL__: string }
describe('MixpanelTelemetryProvider — without configured token', () => {
beforeEach(() => {
@@ -103,6 +104,7 @@ describe('MixpanelTelemetryProvider — without configured token', () => {
describe('MixpanelTelemetryProvider — with configured token', () => {
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ = ''
;(window as unknown as ConfigWindow).__CONFIG__ = {
mixpanel_token: 'test-token'
}
@@ -112,6 +114,26 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
mockNormalizeSurveyResponses.mockImplementation((responses) => responses)
})
it('does not initialize Mixpanel for loopback cloud dev', () => {
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ =
'http://localhost:8188'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const provider = new MixpanelTelemetryProvider()
provider.trackUserLoggedIn()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('disabled in local cloud dev')
)
expect(mockMixpanel.init).not.toHaveBeenCalled()
expect(mockMixpanel.track).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ = ''
}
})
it('initializes Mixpanel and tracks events synchronously after the loaded callback fires', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()

View File

@@ -69,6 +69,8 @@ const DEFAULT_DISABLED_EVENTS = [
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
Object.values(TelemetryEvents) as TelemetryEventName[]
)
const localOriginPattern =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/
interface QueuedEvent {
eventName: TelemetryEventName
@@ -96,6 +98,12 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
if (isLocalCloudDev()) {
console.warn('Mixpanel telemetry disabled in local cloud dev')
this.isEnabled = false
return
}
this.configureDisabledEvents(
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
)
@@ -446,3 +454,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
}
function isLocalCloudDev(): boolean {
return (
import.meta.env.DEV && localOriginPattern.test(__DEV_SERVER_COMFYUI_URL__)
)
}

View File

@@ -15,6 +15,7 @@ import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -110,9 +111,18 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.CREATE_WORKSPACE,
keys: ['create_workspace']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
keys: ['oauth_request_id']
}
])
router.beforeEach((to, _from, next) => {
captureOAuthRequestId(to.query)
next()
})
router.afterEach(() => {
trackPageView()
})
@@ -123,12 +133,14 @@ if (isCloud) {
'cloud-login',
'cloud-signup',
'cloud-forgot-password',
'cloud-oauth-consent',
'cloud-sorry-contact-support'
])
const PUBLIC_ROUTE_PATHS = new Set([
'/cloud/login',
'/cloud/signup',
'/cloud/forgot-password',
'/cloud/oauth/consent',
'/cloud/sorry-contact-support'
])

View File

@@ -88,6 +88,19 @@ const DEV_SEVER_FALLBACK_URL =
const DEV_SERVER_COMFYUI_URL =
DEV_SERVER_COMFYUI_ENV_URL || DEV_SEVER_FALLBACK_URL
/** When cloud dev proxies the frontend at :5173 to a loopback ingest, registry routes must not hit ingest. */
const LOCAL_LOOPBACK_COMFY_BACKEND_PATTERN =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i
const CLOUD_REGISTRY_API_TARGET =
process.env.USE_PROD_CONFIG === 'true'
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
const PROXY_REGISTRY_FOR_LOCAL_CLOUD =
DISTRIBUTION === 'cloud' &&
LOCAL_LOOPBACK_COMFY_BACKEND_PATTERN.test(DEV_SERVER_COMFYUI_URL)
const cloudProxyConfig =
DISTRIBUTION === 'cloud' ? { secure: false, changeOrigin: true } : {}
@@ -203,6 +216,14 @@ export default defineConfig({
return false
}
// Cloud ingest does not host OSS ComfyUI /api/i18n; avoid hard-failing bootstrap.
const pathOnly = req.url?.split('?')[0]
if (DISTRIBUTION === 'cloud' && pathOnly === '/api/i18n') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({}))
return false
}
// Bypass multi-user auth check from staging (cloud only)
if (DISTRIBUTION === 'cloud' && req.url === '/api/users') {
res.setHeader('Content-Type', 'application/json')
@@ -214,6 +235,26 @@ export default defineConfig({
}
},
'/oauth': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig
},
...(PROXY_REGISTRY_FOR_LOCAL_CLOUD
? {
'/customers': {
target: CLOUD_REGISTRY_API_TARGET,
changeOrigin: true,
secure: true
},
'/releases': {
target: CLOUD_REGISTRY_API_TARGET,
changeOrigin: true,
secure: true
}
}
: {}),
'/ws': {
target: DEV_SERVER_COMFYUI_URL,
ws: true,
@@ -625,6 +666,7 @@ export default defineConfig({
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DEV_SERVER_COMFYUI_URL__: JSON.stringify(DEV_SERVER_COMFYUI_URL),
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
},

View File

@@ -46,6 +46,7 @@ globalThis.__SENTRY_DSN__ = ''
globalThis.__ALGOLIA_APP_ID__ = ''
globalThis.__ALGOLIA_API_KEY__ = ''
globalThis.__USE_PROD_CONFIG__ = false
globalThis.__DEV_SERVER_COMFYUI_URL__ = ''
globalThis.__DISTRIBUTION__ = 'localhost'
globalThis.__IS_NIGHTLY__ = false