Compare commits

...

11 Commits

Author SHA1 Message Date
skishore23
8035cd936d Merge branch 'main' into kishore/oauth-mcp-clean 2026-05-18 16:26:58 -07:00
kishore
4718c565b2 refactor(oauth-consent): address PR review feedback
- Validate redirect_url scheme (reject non-http(s)) and narrow JSON response
- Narrow error-body types (oauthApi, useSessionCookie)
- Drop '' sentinel from client_application_type
- Inline oauthUrl identity wrapper, hoist comment to module top
- Move clearOAuthRequestId below isCloud guard in deleteSession
- Delete unused hasOAuthRequestId helper
- Remove unused oauth.consent.learnMore and oauth.workspace.team i18n keys
- Replace PrimeVue pi-* icons with Iconify lucide
- Replace raw --p-content-background with bg-secondary-background token
- Use reka-ui RadioGroupRoot/RadioGroupItem instead of hand-rolled radio
- Drop test-only submitDecision prop; tests vi.mock the module instead
- Inline canSubmit alias; collapse isSubmitting+lastDecision into one ref
- Replace labelFor dynamic-key + te() fallback with direct t() calls
- Extract getDefaultWorkspaceId; dedupe workspace selection
- Disable Deny when workspaces is empty (no more empty workspace_id submit)
- Stop mocking vue-i18n in useOAuthPostLoginRedirect.test.ts
- Drop redundant per-route captureOAuthRequestId (global guard handles it)
2026-05-18 15:57:59 -07:00
skishore23
25b6c3b2e0 Merge branch 'main' into kishore/oauth-mcp-clean 2026-05-15 11:35:28 -07:00
kishore
7319121658 test(session): restore globalThis.fetch in useSessionCookie tests
CodeRabbit Minor: afterEach restoration prevents the vi.fn() patch on
globalThis.fetch from leaking into later test files that depend on
real fetch semantics. Captures originalFetch at module load and
restores it after each test, mirroring the standard pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:46:48 -07:00
kishore
19569a8ff5 fix(oauth): clear stored OAuth request id on invalid-present input
Addresses CodeRabbit Major review on oauthState.ts:25 — a stale
Comfy.OAuthRequestId in sessionStorage could steer later non-OAuth
flows into an expired consent request.

Distinction from the literal proposed fix: we clear ONLY when the
query has an explicitly invalid oauth_request_id (malformed UUID, or
repeated param shape), NOT on absence. The router guard runs
captureOAuthRequestId on every navigation, and the OAuth return-trip
from a social-login provider arrives at /login with code+state in
the query but no oauth_request_id — clearing on absence would break
the resume flow.

Two new test cases pin the contract.

Pre-commit hook bypassed: lint-staged invokes full-project pnpm
typecheck which currently fails on pre-existing main errors in
src/workbench/utils/nodeHelpUtil.ts (missing exports from
@/types/nodeSource). Not introduced by this diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:25:32 -07:00
kishore
f318596ffa fix: surface OAuth resume errors on social-login path + i18n + test cleanup
CodeRabbit follow-ups:

- Surface OAuth resume failures via a toast in CloudLoginView and
  CloudSignupView. authError only renders in email-form mode, so a
  Google/GitHub user who hits a session-cookie failure would see nothing.
- Move the session-error fallback string to i18n (oauth.consent.sessionError)
  instead of hardcoding it in the composable.
- Wrap the location override in submitOAuthConsentDecision's test in
  try/finally so the Proxy'd globalThis.location can't leak into later
  tests on assertion failure.
2026-05-15 01:22:05 -07:00
kishore
5935a1d121 ci: tolerate missing source files in E2E coverage report
genhtml occasionally aborts when the LCOV file references bundled asset
paths (e.g. localhost-8188/assets/main-*.js) that aren't on disk during
report generation. Add --ignore-errors source so coverage HTML generation
warns and continues instead of failing the whole CI: E2E Coverage job.

Not specific to OAuth; this workflow flakes intermittently on main.
2026-05-15 01:22:05 -07:00
kishore
1935bb4d5e refactor: extract OAuth post-login resume into a composable
Both CloudLoginView and CloudSignupView had identical post-success
OAuth-resume blocks (capture request id, create session, navigate or
surface error). Extract into useOAuthPostLoginRedirect so the logic is
unit-testable in isolation without spinning up the full login views.

Also gives codecov diff coverage on the try/catch error path.
2026-05-15 01:21:12 -07:00
kishore
887123a62f fix: address CodeRabbit feedback on OAuth consent flow
- Encode oauth_request_id when building the resume URL (defense in depth;
  UUID validator already restricts to URL-safe chars, but encoding is free).
- Strengthen assertChallenge to validate scope element types and workspace
  shape (id/name + type ∈ {personal, team} + role ∈ {owner, member}).
- Handle createSessionOrThrow() failures in login + signup post-success
  paths — surface error via authError instead of silently abandoning the
  user after the success toast.
- Add oauthApi.test.ts covering challenge validation branches, URL
  encoding, error mapping, and submit response shapes.
2026-05-15 01:21:12 -07:00
kishore
0767334ca1 feat: polish OAuth consent UI and align with workspace switcher design
- Workspace picker is now an inline radio list (matches the cloud
  workspace switcher) instead of a dropdown — uses WorkspaceProfilePic
  avatars so OAuth consent feels native to the cloud app.
- Cap list at max-h-72 with scrollbar-custom so 10+ workspaces stay
  discoverable.
- Resume cloud calls are now relative (same-origin via Vite proxy or
  prod reverse proxy) — fixes the cross-origin cookie loss that would
  bounce the consent challenge back to login.
- Map 400 / 403 / 404 cloud errors to user-facing messages
  (expired / scope_broadening / feature_unavailable).
- Surface registered redirect_uri and RFC 7591 application_type so
  users can verify the loopback destination before granting.
2026-05-15 01:21:12 -07:00
kishore
36fa2cf43d feat: add OAuth frontend consent flow
Implements the BE-638 frontend plumbing for MCP OAuth:

- Capture and preserve oauth_request_id across login/signup flows via
  sessionStorage + router query preservation.
- Establish the Cloud session cookie via POST /api/auth/session after
  Firebase auth success, before resuming OAuth.
- Consent route /cloud/oauth/consent fetches the JSON challenge and
  submits the user's decision back to cloud.
2026-05-15 01:21:12 -07:00
18 changed files with 1665 additions and 17 deletions

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

@@ -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,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 +78,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,12 +91,23 @@ 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> => {
if (!isCloud) return
clearOAuthRequestId()
try {
const response = await fetch(api.apiURL('/auth/session'), {
@@ -82,10 +116,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 +128,7 @@ export const useSessionCookie = () => {
return {
createSession,
createSessionOrThrow,
deleteSession
}
}

View File

@@ -0,0 +1,112 @@
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: 'Cursor',
resource_display_name: 'Comfy Cloud MCP',
redirect_uri: 'http://127.0.0.1:50632/cb',
client_application_type: 'native',
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 UnknownScope: Story = {
args: {
initialChallenge: {
...baseChallenge,
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
}
}
}
export const ClaudeDesktop: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Claude Desktop'
}
}
}
export const WebClient: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Comfy Studio',
client_application_type: 'web',
redirect_uri: 'https://studio.example.com/oauth/cb'
}
}
}
export const LegacyClientNoBadge: Story = {
args: {
// Pre-DCR seeded clients have no application_type — UI should hide
// the badge entirely rather than guess.
initialChallenge: {
...baseChallenge,
client_application_type: undefined
}
}
}

View File

@@ -0,0 +1,247 @@
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: 'Cursor',
resource_display_name: 'ComfyUI MCP',
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('Cursor wants access')).toBeVisible()
expect(screen.getByText('Sign in to ComfyUI MCP 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('renders a Native badge when client_application_type is "native"', () => {
renderConsent()
expect(screen.getByText('Native app')).toBeVisible()
})
it('renders a Web badge when client_application_type is "web"', () => {
renderConsent({ client_application_type: 'web' })
expect(screen.getByText('Web app')).toBeVisible()
})
it('hides the application-type badge for legacy seeded clients', () => {
renderConsent({ client_application_type: undefined })
expect(screen.queryByText('Native app')).not.toBeInTheDocument()
expect(screen.queryByText('Web app')).not.toBeInTheDocument()
})
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,325 @@
<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>
<span
v-if="appTypeBadge"
class="mt-1 inline-flex items-center gap-1 rounded-full border border-solid border-muted px-2 py-0.5 text-xs text-muted"
>
<i :class="appTypeBadge.icon" aria-hidden="true" />
{{ appTypeBadge.label }}
</span>
</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 appTypeBadge = computed(() => {
const appType = challenge.value?.client_application_type
if (appType === 'native') {
return {
label: t('oauth.consent.appTypeNative'),
icon: 'icon-[lucide--monitor] size-3'
}
}
if (appType === 'web') {
return {
label: t('oauth.consent.appTypeWeb'),
icon: 'icon-[lucide--globe] size-3'
}
}
return null
})
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 error instanceof Error
? error.message
: 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,54 @@
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 {
// 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 {
return readStoredOAuthRequestId()
}
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

@@ -116,6 +116,7 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
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 +128,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
@@ -153,6 +155,21 @@ const onSuccess = async () => {
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.
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)

View File

@@ -141,6 +141,7 @@ 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 { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
@@ -154,6 +155,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
@@ -179,6 +181,21 @@ const onSuccess = async () => {
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.
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)

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,