[backport cloud/1.44] feat: OAuth consent UI for authorization (BE-638) (#12470)

Backport of #12159 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: skishore23 <shimikeri.kishore@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Comfy Org PR Bot
2026-05-27 08:07:12 +09:00
committed by GitHub
parent 553e9e3b11
commit bef26f6dd4
20 changed files with 1647 additions and 62 deletions

View File

@@ -1,3 +1,4 @@
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import { useExtensionService } from '@/services/extensionService'
@@ -19,6 +20,7 @@ useExtensionService().registerExtension({
},
onAuthUserLogout: async () => {
clearOAuthRequestId()
const { deleteSession } = useSessionCookie()
await deleteSession()
}

View File

@@ -2133,6 +2133,43 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"oauth": {
"consent": {
"allow": "Continue",
"deny": "Cancel",
"genericError": "OAuth request failed. Please restart from the client app.",
"loading": "Loading authorization request…",
"missingRequest": "This authorization request is missing. Please restart from the client app.",
"noWorkspaces": "No eligible workspaces are available for this request.",
"title": "{client} wants access",
"subtitle": "Sign in to {resource} to continue",
"resourceFallback": "this app",
"workspaceLabel": "Workspace",
"permissionsHeader": "Permissions",
"workspaceHelp": "Permissions apply to this workspace only.",
"redirectNotice": "You'll be redirected to",
"appTypeNative": "Native app",
"appTypeWeb": "Web app",
"errorExpired": "This consent request has expired or has already been used. Please restart from the client app.",
"errorScopeBroadening": "The previously approved permissions don't cover this request. You'll need to re-authorize with the new permissions.",
"errorUnavailable": "This feature isn't available right now. Please contact support if the problem persists.",
"sessionError": "Failed to establish session. Please try again.",
"sessionErrorToastSummary": "Couldn't continue OAuth sign-in"
},
"scopes": {
"mcp:tools:read": {
"label": "View available workflow tools"
},
"mcp:tools:call": {
"label": "Run workflows on your behalf"
}
},
"workspace": {
"personal": "Personal",
"owner": "Owner",
"member": "Member"
}
},
"auth": {
"apiKey": {
"title": "API Key",

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetIdToken = vi.fn()
const originalFetch = globalThis.fetch
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()
})
afterEach(() => {
// Restore the global fetch so a leaked mock doesn't bleed into later
// tests that depend on real fetch semantics.
globalThis.fetch = originalFetch
})
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

@@ -8,6 +8,36 @@ 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: unknown = await response.json().catch(() => null)
const message = (errorData as { message?: unknown } | null)?.message
return typeof message === 'string' ? 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,6 +90,16 @@ 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.
@@ -82,10 +114,9 @@ export const useSessionCookie = () => {
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to delete session cookie:',
errorData.message || response.statusText
await readSessionError(response)
)
}
} catch (error) {
@@ -95,6 +126,7 @@ export const useSessionCookie = () => {
return {
createSession,
createSessionOrThrow,
deleteSession
}
}

View File

@@ -0,0 +1,98 @@
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 baseChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'preview-csrf-token',
client_display_name: 'Comfy Desktop',
resource_display_name: 'Comfy Cloud',
redirect_uri: 'http://127.0.0.1:50632/cb',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Comfy Team',
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 TwoWorkspaces: Story = {
args: { initialChallenge: baseChallenge }
}
export const SingleWorkspace: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [baseChallenge.workspaces[0]]
}
}
}
export const ManyWorkspaces: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [
baseChallenge.workspaces[0],
baseChallenge.workspaces[1],
{
id: 'design-team',
name: 'Design Studio',
type: 'team',
role: 'owner'
},
{
id: 'agency-team',
name: 'Agency Workspace',
type: 'team',
role: 'member'
}
]
}
}
}
export const NoWorkspaces: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: []
}
}
}
export const UnknownScope: Story = {
args: {
initialChallenge: {
...baseChallenge,
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
}
}
}
export const ComfyCli: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Comfy CLI'
}
}
}

View File

@@ -0,0 +1,231 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import { OAuthApiError } from '@/platform/cloud/oauth/oauthApi'
import type * as oauthApi from '@/platform/cloud/oauth/oauthApi'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const submitOAuthConsentDecision = vi.fn()
vi.mock('@/platform/cloud/oauth/oauthApi', async () => {
const actual = await vi.importActual<typeof oauthApi>(
'@/platform/cloud/oauth/oauthApi'
)
return {
...actual,
submitOAuthConsentDecision: (
...args: Parameters<typeof actual.submitOAuthConsentDecision>
) => submitOAuthConsentDecision(...args)
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
singleSelectDropdown: 'Select an option'
},
oauth: {
consent: {
allow: 'Continue',
deny: 'Cancel',
genericError: 'OAuth request failed.',
loading: 'Loading authorization request…',
missingRequest: 'This authorization request is missing.',
noWorkspaces: 'No eligible workspaces are available.',
title: '{client} wants access',
subtitle: 'Sign in to {resource} to continue',
workspaceLabel: 'Workspace',
permissionsHeader: 'Permissions',
workspaceHelp: 'Permissions apply to this workspace only.',
redirectNotice: "You'll be redirected to",
appTypeNative: 'Native app',
appTypeWeb: 'Web app',
errorExpired:
'This consent request has expired or has already been used.',
errorScopeBroadening:
"The previously approved permissions don't cover this request.",
errorUnavailable: "This feature isn't available right now."
},
scopes: {
'mcp:tools:read': {
label: 'View available workflow tools'
},
'mcp:tools:call': {
label: 'Run workflows on your behalf'
}
},
workspace: {
personal: 'Personal',
owner: 'Owner',
member: 'Member'
}
}
}
}
})
const challenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Comfy Desktop',
resource_display_name: 'Comfy Cloud',
redirect_uri: 'http://127.0.0.1:50632/cb',
client_application_type: 'native',
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> = {}) =>
render(OAuthConsentView, {
global: { plugins: [i18n] },
props: {
initialChallenge: { ...challenge, ...overrides }
}
})
describe('OAuthConsentView', () => {
beforeEach(() => {
submitOAuthConsentDecision.mockReset().mockResolvedValue(undefined)
})
it('renders title, subtitle, and scope checklist', () => {
renderConsent()
// Title is "<client> wants access". Subtitle is "Sign in to <resource>
// to continue". Both are short and avoid repeating any brand name twice.
expect(screen.getByText('Comfy Desktop wants access')).toBeVisible()
expect(screen.getByText('Sign in to Comfy Cloud to continue')).toBeVisible()
// Permissions section header is just the static word "Permissions".
expect(screen.getByText('Permissions')).toBeVisible()
// Known scopes render their human-readable labels. We deliberately
// avoid MCP jargon ("tools", "metadata") — the user thinks in
// ComfyUI vocabulary (workflows), and the consent UI doesn't show
// an enumerated tool list, so the label shouldn't promise one.
expect(screen.getByText('View available workflow tools')).toBeVisible()
expect(screen.getByText('Run workflows on your behalf')).toBeVisible()
// Unknown scopes fall back to the raw scope string so a new resource
// doesn't require a frontend release just to render its consent page.
expect(screen.getByText('mcp:unknown:test')).toBeVisible()
})
it('renders the registered redirect URI verbatim', () => {
renderConsent()
// Verbatim render — the user must be able to read the loopback URL
// and verify it's the localhost callback their CLI is listening on.
expect(screen.getByText('http://127.0.0.1:50632/cb')).toBeVisible()
expect(screen.getByText("You'll be redirected to")).toBeVisible()
})
it('preselects the only workspace and submits with it', async () => {
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
// Single-workspace path: Allow is enabled and submission carries the
// sole workspace_id.
await user.click(screen.getByRole('button', { name: 'Continue' }))
expect(submitOAuthConsentDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace'
})
})
it('keeps Allow disabled when multiple workspaces are available and none is chosen', () => {
renderConsent()
const allow = screen.getByRole('button', { name: 'Continue' })
expect(allow).toBeDisabled()
})
it('submits deny when the user cancels', async () => {
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(submitOAuthConsentDecision).toHaveBeenCalledWith(
expect.objectContaining({
decision: 'deny',
workspaceId: 'personal-workspace'
})
)
})
it('disables both buttons when no workspaces are available', () => {
renderConsent({ workspaces: [] })
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
})
it('maps OAuthApiError(400) to the expired-request message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('expired', 400)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
'This consent request has expired or has already been used.'
)
).toBeVisible()
})
})
it('maps OAuthApiError(403) to the scope-broadening re-prompt message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('scope broadening', 403)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
"The previously approved permissions don't cover this request."
)
).toBeVisible()
})
})
it('maps OAuthApiError(404) to the feature-unavailable message', async () => {
submitOAuthConsentDecision.mockRejectedValue(
new OAuthApiError('disabled', 404)
)
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] })
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText("This feature isn't available right now.")
).toBeVisible()
})
})
})

View File

@@ -0,0 +1,299 @@
<template>
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center p-6">
<section
v-if="challenge"
class="flex flex-col gap-6 rounded-2xl border border-solid border-muted bg-secondary-background p-6 shadow-sm"
>
<header class="flex flex-col items-center gap-3 pt-2 text-center">
<div
class="flex size-12 items-center justify-center rounded-2xl bg-secondary-background"
>
<i
class="icon-[lucide--key] size-5 text-base-foreground"
aria-hidden="true"
/>
</div>
<div class="flex flex-col items-center gap-1.5">
<h1 class="m-0 text-xl/tight font-semibold">
{{
t('oauth.consent.title', {
client: challenge.client_display_name
})
}}
</h1>
<p class="m-0 text-sm text-muted">
{{ t('oauth.consent.subtitle', { resource: resourceName }) }}
</p>
</div>
</header>
<section class="flex flex-col gap-2">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.workspaceLabel') }}
</p>
<div
v-if="challenge.workspaces.length === 0"
class="p-3 text-sm text-muted"
>
{{ t('oauth.consent.noWorkspaces') }}
</div>
<RadioGroupRoot
v-else
v-model="selectedWorkspaceId"
:aria-label="t('oauth.consent.workspaceLabel')"
class="m-0 flex scrollbar-custom max-h-72 list-none flex-col gap-1 overflow-y-auto p-0"
>
<RadioGroupItem
v-for="workspace in challenge.workspaces"
:key="workspace.id"
:value="workspace.id"
:class="
cn(
'flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-left transition-colors',
'hover:bg-secondary-background-hover',
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
selectedWorkspaceId === workspace.id &&
'bg-secondary-background'
)
"
>
<WorkspaceProfilePic
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span class="text-xs text-muted-foreground">
{{ workspaceSecondaryLabel(workspace) }}
</span>
</div>
<i
v-if="selectedWorkspaceId === workspace.id"
class="icon-[lucide--check] size-4 shrink-0 text-base-foreground"
aria-hidden="true"
/>
</RadioGroupItem>
</RadioGroupRoot>
<p class="m-0 text-xs text-muted">
{{ t('oauth.consent.workspaceHelp') }}
</p>
</section>
<section class="flex flex-col gap-3">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.permissionsHeader') }}
</p>
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="scope in challenge.scopes"
:key="scope"
class="flex items-center gap-2"
>
<i
class="icon-[lucide--check] size-4 shrink-0 text-primary-background"
aria-hidden="true"
/>
<span class="text-sm">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</section>
<section
v-if="challenge.redirect_uri"
class="flex flex-col gap-1.5 rounded-lg border border-solid border-muted bg-secondary-background/40 p-3"
>
<span class="text-xs text-muted">
{{ t('oauth.consent.redirectNotice') }}
</span>
<code
class="m-0 truncate font-mono text-xs text-base-foreground"
:title="challenge.redirect_uri"
>
{{ challenge.redirect_uri }}
</code>
</section>
<p
v-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<footer class="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
class="w-full"
:loading="submitting === 'allow'"
:disabled="isSubmitting || !selectedWorkspaceIsValid"
@click="submit('allow')"
>
{{ t('oauth.consent.allow') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
:loading="submitting === 'deny'"
:disabled="isSubmitting || challenge.workspaces.length === 0"
@click="submit('deny')"
>
{{ t('oauth.consent.deny') }}
</Button>
</footer>
</section>
<p
v-else-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-center text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<p v-else class="m-0 text-center text-sm text-muted">
{{ t('oauth.consent.loading') }}
</p>
</main>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import {
OAuthApiError,
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type {
OAuthConsentChallenge,
OAuthWorkspace
} from '@/platform/cloud/oauth/oauthApi'
import {
clearOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
const { initialChallenge } = defineProps<{
initialChallenge?: OAuthConsentChallenge
}>()
const { t, te } = useI18n()
const route = useRoute()
function getDefaultWorkspaceId(
source: OAuthConsentChallenge | undefined
): string | undefined {
return source?.workspaces.length === 1 ? source.workspaces[0].id : undefined
}
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
const selectedWorkspaceId = ref<string | undefined>(
getDefaultWorkspaceId(initialChallenge)
)
const errorMessage = ref('')
const submitting = ref<'allow' | 'deny' | null>(null)
const isSubmitting = computed(() => submitting.value !== null)
const resourceName = computed(
() =>
challenge.value?.resource_display_name ??
t('oauth.consent.resourceFallback')
)
const selectedWorkspaceIsValid = computed(() =>
Boolean(
selectedWorkspaceId.value &&
challenge.value?.workspaces.some(
(workspace) => workspace.id === selectedWorkspaceId.value
)
)
)
function scopeLabel(scope: string): string {
const key = `oauth.scopes.${scope}.label`
return te(key) ? t(key) : scope
}
// Row's secondary label: personal workspaces show "Personal" (role is
// always implicit owner); team workspaces show the role ("Owner"/"Member").
function workspaceSecondaryLabel(workspace: OAuthWorkspace): string {
if (workspace.type === 'personal') return t('oauth.workspace.personal')
return workspace.role === 'owner'
? t('oauth.workspace.owner')
: t('oauth.workspace.member')
}
function requestIdFromRoute(): string | null {
return typeof route.query.oauth_request_id === 'string'
? route.query.oauth_request_id
: getOAuthRequestId()
}
async function loadChallenge() {
const oauthRequestId = requestIdFromRoute()
if (!oauthRequestId) {
errorMessage.value = t('oauth.consent.missingRequest')
return
}
try {
const next = await fetchOAuthConsentChallenge(oauthRequestId)
challenge.value = next
selectedWorkspaceId.value = getDefaultWorkspaceId(next)
} catch (error) {
errorMessage.value = messageForError(error)
}
}
function messageForError(error: unknown): string {
if (error instanceof OAuthApiError) {
if (error.status === 400) return t('oauth.consent.errorExpired')
if (error.status === 403) return t('oauth.consent.errorScopeBroadening')
if (error.status === 404) return t('oauth.consent.errorUnavailable')
}
return t('oauth.consent.genericError')
}
async function submit(decision: 'allow' | 'deny') {
if (!challenge.value) return
if (decision === 'allow' && !selectedWorkspaceIsValid.value) return
// Cloud requires workspace_id on both allow and deny. A deny with no
// workspaces is disabled in the template, so a workspace is guaranteed.
const workspaceId =
selectedWorkspaceId.value ?? challenge.value.workspaces[0]?.id
if (!workspaceId) return
errorMessage.value = ''
submitting.value = decision
try {
await submitOAuthConsentDecision({
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId
})
clearOAuthRequestId()
} catch (error) {
errorMessage.value = messageForError(error)
} finally {
submitting.value = null
}
}
onMounted(() => {
if (!initialChallenge) {
void loadChallenge()
}
})
</script>

View File

@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
OAuthApiError,
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const validChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Cursor',
scopes: ['mcp:tools:read'],
workspaces: [
{
id: 'personal-workspace',
name: 'Kishore',
type: 'personal',
role: 'owner'
}
]
}
const okResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
const errorResponse = (status: number, message: string) =>
new Response(JSON.stringify({ message }), {
status,
headers: { 'Content-Type': 'application/json' }
})
describe('fetchOAuthConsentChallenge', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns the parsed challenge on 200', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(validChallenge))
const result = await fetchOAuthConsentChallenge(
validChallenge.oauth_request_id
)
expect(result).toEqual(validChallenge)
})
it('URL-encodes the oauth_request_id', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(okResponse(validChallenge))
// Reserved characters get percent-encoded (defense-in-depth — valid UUIDs
// never contain these chars, but the call should be safe regardless).
await fetchOAuthConsentChallenge('id with spaces&injected=evil')
const url = fetchSpy.mock.calls[0]?.[0] as string
expect(url).toContain(
'oauth_request_id=id%20with%20spaces%26injected%3Devil'
)
})
it('throws OAuthApiError with status on non-2xx', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
errorResponse(400, 'expired')
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toMatchObject({
name: 'OAuthApiError',
status: 400
})
})
it('rejects when scopes are not strings', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ ...validChallenge, scopes: [123, 'mcp:tools:read'] })
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when a workspace is missing required fields', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'personal' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when workspace.type is not personal or team', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'enterprise', role: 'owner' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when workspace.role is not owner or member', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
...validChallenge,
workspaces: [{ id: 'x', name: 'y', type: 'team', role: 'admin' }]
})
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects when top-level fields are missing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ ...validChallenge, csrf_token: undefined })
)
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
it('rejects null body', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(null))
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
'OAuth consent challenge is invalid'
)
})
})
describe('submitOAuthConsentDecision', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('navigates to the redirect_url returned by cloud on success', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: 'http://127.0.0.1:50632/cb?code=xyz' })
)
// jsdom location is not writable directly; replace href via spy.
const originalLocation = globalThis.location
const hrefSetter = vi.fn()
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new Proxy(originalLocation, {
set(_target, prop, value) {
if (prop === 'href') {
hrefSetter(value)
return true
}
return Reflect.set(originalLocation, prop, value)
},
get(_target, prop) {
return Reflect.get(originalLocation, prop)
}
})
})
try {
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
expect(hrefSetter).toHaveBeenCalledWith(
'http://127.0.0.1:50632/cb?code=xyz'
)
} finally {
// Restore unconditionally so an assertion failure doesn't leak the
// Proxy'd location into later tests.
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation
})
}
})
it('throws OAuthApiError on non-2xx', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
errorResponse(403, 'scope broadening')
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toBeInstanceOf(OAuthApiError)
})
it('throws when redirect_url is missing from a successful response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse({}))
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toThrow('redirect_url')
})
it('rejects an unsafe redirect_url scheme', async () => {
// Defense in depth: even though the cloud backend is trusted, never
// hand the browser off to a non-http(s) URL.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: 'javascript:alert(1)' })
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace'
})
).rejects.toThrow('unsafe scheme')
})
it('sends the expected JSON body', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(okResponse({ redirect_url: 'http://x.test/' }))
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'deny',
workspaceId: 'personal-workspace'
})
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit
expect(JSON.parse(init.body as string)).toEqual({
oauth_request_id: validChallenge.oauth_request_id,
csrf_token: validChallenge.csrf_token,
decision: 'deny',
workspace_id: 'personal-workspace'
})
})
})

View File

@@ -0,0 +1,156 @@
// All OAuth calls are relative-URL (same-origin) on purpose. useSessionCookie
// POSTs /api/auth/session through the Vite dev-server proxy (or the production
// same-host ingress), so the Set-Cookie response lands on the FE origin. A
// cross-origin fetch to a different cloud host wouldn't include that cookie,
// so the consent challenge would 302 to login (and trip browser cross-origin
// redirect rules to boot — the symptom looks like "CORS error" on a fetch
// initiated from /oauth/authorize). The Vite proxy / production ingress is
// the single point of routing.
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
/**
* Exact registered redirect URI the OAuth client will be sent to on
* success/deny. Surfaced verbatim so users can verify the destination
* (RFC 8252 loopback for CLIs, HTTPS for web clients).
*/
redirect_uri?: string
/**
* RFC 7591 application_type — "native" (CLI/desktop, loopback redirect)
* or "web" (HTTPS-hosted). Absent for legacy seeded clients. Used to render
* a Native / Web badge so users know what kind of app they're authorizing.
*/
client_application_type?: 'native' | 'web'
scopes: string[]
workspaces: OAuthWorkspace[]
}
export type OAuthConsentDecisionParams = {
oauthRequestId: string
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
export class OAuthApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'OAuthApiError'
}
}
async function readErrorMessage(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => null)
const message = (body as { message?: unknown } | null)?.message
return typeof message === 'string' ? 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) ||
!challenge.scopes.every((scope) => typeof scope === 'string') ||
!Array.isArray(challenge.workspaces) ||
!challenge.workspaces.every(isValidWorkspace)
) {
throw new Error('OAuth consent challenge is invalid')
}
}
function isValidWorkspace(value: unknown): value is OAuthWorkspace {
if (typeof value !== 'object' || value === null) return false
const workspace = value as Partial<OAuthWorkspace>
return (
typeof workspace.id === 'string' &&
typeof workspace.name === 'string' &&
(workspace.type === 'personal' || workspace.type === 'team') &&
(workspace.role === 'owner' || workspace.role === 'member')
)
}
export async function fetchOAuthConsentChallenge(
oauthRequestId: string
): Promise<OAuthConsentChallenge> {
const response = await fetch(
`/oauth/authorize?oauth_request_id=${encodeURIComponent(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('/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: unknown = await response.json()
const redirectUrl = (body as { redirect_url?: unknown } | null)?.redirect_url
if (typeof redirectUrl !== 'string') {
throw new Error('OAuth consent response did not include redirect_url')
}
// Defense in depth: even though the cloud backend is trusted, never hand
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
// execute in our origin.
const target = new URL(redirectUrl, globalThis.location.origin)
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
globalThis.location.href = redirectUrl
}

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
captureOAuthRequestId,
clearOAuthRequestId,
getOAuthRequestId
} 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(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('preserves a stored id when the query has no oauth_request_id key', () => {
// The router guard runs on every navigation, including the OAuth
// return-trip from a social-login provider (Google / GitHub) which
// arrives at /login with `code` + `state` but no oauth_request_id.
// The previously-captured id MUST survive that hop.
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ code: 'oauth-provider-code', state: 'xyz' })
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
})
it('clears a stored id when the query has an invalid oauth_request_id', () => {
// Stale deep-link or probing — drop the stored value rather than let
// it steer later flows into an expired consent request.
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
expect(getOAuthRequestId()).toBeNull()
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
})
it('clears a stored id when the query has a repeated oauth_request_id', () => {
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
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,50 @@
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-7][0-9a-f]{3}-[0-9a-f]{4}-[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)
}
export function captureOAuthRequestId(query: LocationQuery): string | null {
// The router guard calls this on every navigation. We can't unconditionally
// clear on absence — the OAuth return-trip from a social-login provider
// (Google / GitHub) arrives at /login with `code` + `state` in the query
// but no `oauth_request_id`, and we need the previously-captured value to
// survive that hop.
//
// We DO clear on an explicitly invalid value (present but malformed): that
// shape is either a stale deep-link or probing, and a stale Comfy.OAuthRequestId
// contaminating later flows is worse than dropping the bad input.
const raw = query.oauth_request_id
const value = readQueryString(raw)
if (!value) {
if (raw !== undefined) {
// Present but non-string (e.g. repeated `?oauth_request_id=a&oauth_request_id=b`).
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}
return null
}
if (!isOAuthRequestId(value)) {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return null
}
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
return value
}
export function getOAuthRequestId(): string | null {
const value = sessionStorage.getItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return value && isOAuthRequestId(value) ? value : null
}
export function clearOAuthRequestId(): void {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
const routerPush = vi.fn().mockResolvedValue(undefined)
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush })
}))
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
useSessionCookie: () => ({ createSessionOrThrow })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function mountRedirect() {
let api: ReturnType<typeof useOAuthPostLoginRedirect> | undefined
const Child = defineComponent({
setup() {
api = useOAuthPostLoginRedirect()
return () => null
}
})
const host = document.createElement('div')
const app = createApp(defineComponent({ setup: () => () => h(Child) }))
app.use(i18n)
app.mount(host)
if (!api) throw new Error('useOAuthPostLoginRedirect was not initialized')
return { api, unmount: () => app.unmount() }
}
describe('useOAuthPostLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear()
routerPush.mockClear()
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
})
it('returns no-oauth when neither query nor sessionStorage holds a request id', async () => {
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({})
expect(result).toEqual({ kind: 'no-oauth' })
expect(createSessionOrThrow).not.toHaveBeenCalled()
expect(routerPush).not.toHaveBeenCalled()
})
it('establishes session and navigates to consent when oauth_request_id is in the query', async () => {
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
expect(createSessionOrThrow).toHaveBeenCalledOnce()
expect(routerPush).toHaveBeenCalledWith({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
expect(result).toEqual({ kind: 'resumed' })
})
it('resumes using a stashed sessionStorage id when the query is empty (multi-step flows)', async () => {
sessionStorage.setItem('Comfy.OAuthRequestId', VALID_REQUEST_ID)
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({})
expect(result).toEqual({ kind: 'resumed' })
expect(routerPush).toHaveBeenCalledWith({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
})
it('returns an error with the thrown message when session creation fails', async () => {
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
expect(result).toEqual({ kind: 'error', message: 'Unauthorized' })
expect(routerPush).not.toHaveBeenCalled()
})
it('falls back to the i18n key when session creation rejects with a non-Error value', async () => {
createSessionOrThrow.mockRejectedValue('boom')
const { api } = mountRedirect()
const result = await api.resumeOAuthIfNeeded({
oauth_request_id: VALID_REQUEST_ID
})
// Empty messages → useI18n returns the key itself, which is what we
// assert on (per docs/testing/vitest-patterns.md).
expect(result).toEqual({
kind: 'error',
message: 'oauth.consent.sessionError'
})
expect(routerPush).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,53 @@
import { useI18n } from 'vue-i18n'
import type { LocationQuery } from 'vue-router'
import { useRouter } from 'vue-router'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
type OAuthResumeResult =
| { kind: 'no-oauth' }
| { kind: 'resumed' }
| { kind: 'error'; message: string }
/**
* Post-login OAuth resume. If the current login flow originated from an OAuth
* authorize request, establishes the Cloud session cookie and navigates to the
* consent route. Used by both `CloudLoginView` and `CloudSignupView`.
*/
export function useOAuthPostLoginRedirect() {
const router = useRouter()
const sessionCookie = useSessionCookie()
const { t } = useI18n()
async function resumeOAuthIfNeeded(
query: LocationQuery
): Promise<OAuthResumeResult> {
captureOAuthRequestId(query)
const oauthRequestId = getOAuthRequestId()
if (!oauthRequestId) return { kind: 'no-oauth' }
try {
await sessionCookie.createSessionOrThrow()
} catch (error) {
return {
kind: 'error',
message:
error instanceof Error
? error.message
: t('oauth.consent.sessionError')
}
}
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return { kind: 'resumed' }
}
return { resumeOAuthIfNeeded }
}

View File

@@ -118,8 +118,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
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'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -129,10 +128,14 @@ const route = useRoute()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,
successSummary: 'Login Completed',
defaultRedirect: () => ({ name: 'cloud-user-check' })
})
function switchToEmailForm() {
showEmailForm.value = true
@@ -146,40 +149,24 @@ const navigateToSignup = async () => {
await router.push({ name: 'cloud-signup', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Login Completed',
life: 2000
})
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
await router.push({ name: 'cloud-user-check' })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onSuccess()
await onAuthSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onSuccess()
await onAuthSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
authError.value = ''
if (await authActions.signInWithEmail(values.email, values.password)) {
await onSuccess()
await onAuthSuccess()
}
}
</script>

View File

@@ -142,10 +142,9 @@ import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -157,7 +156,6 @@ const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
const {
showEmailForm,
@@ -167,46 +165,34 @@ const {
switchToSocialLogin
} = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,
successSummary: 'Sign up Completed',
defaultRedirect: () => ({ path: '/', query: route.query })
})
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Sign up Completed',
life: 2000
})
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
// Default redirect to the normal onboarding flow
await router.push({ path: '/', query: route.query })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle({ isNewUser: true })) {
await onSuccess()
await onAuthSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub({ isNewUser: true })) {
await onSuccess()
await onAuthSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
authError.value = ''
if (await authActions.signUpWithEmail(values.email, values.password)) {
await onSuccess()
await onAuthSuccess()
}
}

View File

@@ -0,0 +1,58 @@
import type { Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
/**
* Shared post-authentication redirect logic used by both CloudLoginView and
* CloudSignupView. Handles OAuth resume, previousFullPath redirect, and
* default redirect after successful sign-in or sign-up.
*/
export function usePostAuthRedirect(options: {
authError: Ref<string>
successSummary: string
defaultRedirect: () => RouteLocationRaw
}) {
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const toastStore = useToastStore()
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
async function onAuthSuccess() {
toastStore.add({
severity: 'success',
summary: options.successSummary,
life: 2000
})
const oauthResume = await resumeOAuthIfNeeded(route.query)
if (oauthResume.kind === 'error') {
// authError renders only in email-form mode; surface the failure via
// a toast so social-login users (Google / GitHub) can see it too.
options.authError.value = oauthResume.message
toastStore.add({
severity: 'error',
summary: t('oauth.consent.sessionErrorToastSummary'),
detail: oauthResume.message,
life: 4000
})
return
}
if (oauthResume.kind === 'resumed') return
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)
return
}
await router.push(options.defaultRedirect())
}
return { onAuthSuccess }
}

View File

@@ -1,5 +1,20 @@
import type { RouteRecordRaw } from 'vue-router'
import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
// `oauth_request_id` capture lives in the global router.beforeEach guard
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
// guards read it back via getOAuthRequestId().
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',
@@ -19,9 +34,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()
@@ -39,7 +52,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next({ name: 'cloud-user-check' })
return next(oauthConsentRedirect())
}
}
next()
@@ -58,6 +71,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

@@ -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

@@ -214,6 +214,11 @@ export default defineConfig({
}
},
'/oauth': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig
},
'/ws': {
target: DEV_SERVER_COMFYUI_URL,
ws: true,