diff --git a/src/extensions/core/cloudSessionCookie.ts b/src/extensions/core/cloudSessionCookie.ts index fc9ffcb80c..858bf849fc 100644 --- a/src/extensions/core/cloudSessionCookie.ts +++ b/src/extensions/core/cloudSessionCookie.ts @@ -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() } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 0e0c28095a..eb05174a39 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/platform/auth/session/useSessionCookie.test.ts b/src/platform/auth/session/useSessionCookie.test.ts new file mode 100644 index 0000000000..df2f92993c --- /dev/null +++ b/src/platform/auth/session/useSessionCookie.test.ts @@ -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' + ) + }) +}) diff --git a/src/platform/auth/session/useSessionCookie.ts b/src/platform/auth/session/useSessionCookie.ts index 03806c625b..156ca084b5 100644 --- a/src/platform/auth/session/useSessionCookie.ts +++ b/src/platform/auth/session/useSessionCookie.ts @@ -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 + ): Promise => { + return await fetch(api.apiURL('/auth/session'), { + method: 'POST', + credentials: 'include', + headers: { + ...authHeader, + 'Content-Type': 'application/json' + } + }) + } + + const readSessionError = async (response: Response): Promise => { + 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 + > => { + 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 => { + 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 } } diff --git a/src/platform/cloud/oauth/OAuthConsentView.stories.ts b/src/platform/cloud/oauth/OAuthConsentView.stories.ts new file mode 100644 index 0000000000..3d7b2a732c --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.stories.ts @@ -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 = { + title: 'Cloud/OAuth/Consent', + component: OAuthConsentView +} +export default meta +type Story = StoryObj + +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' + } + } +} diff --git a/src/platform/cloud/oauth/OAuthConsentView.test.ts b/src/platform/cloud/oauth/OAuthConsentView.test.ts new file mode 100644 index 0000000000..a3a12c8e8e --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.test.ts @@ -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( + '@/platform/cloud/oauth/oauthApi' + ) + return { + ...actual, + submitOAuthConsentDecision: ( + ...args: Parameters + ) => 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 = {}) => + 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 " wants access". Subtitle is "Sign in to + // 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() + }) + }) +}) diff --git a/src/platform/cloud/oauth/OAuthConsentView.vue b/src/platform/cloud/oauth/OAuthConsentView.vue new file mode 100644 index 0000000000..d570496cff --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.vue @@ -0,0 +1,299 @@ + + + diff --git a/src/platform/cloud/oauth/oauthApi.test.ts b/src/platform/cloud/oauth/oauthApi.test.ts new file mode 100644 index 0000000000..94c3022f7d --- /dev/null +++ b/src/platform/cloud/oauth/oauthApi.test.ts @@ -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' + }) + }) +}) diff --git a/src/platform/cloud/oauth/oauthApi.ts b/src/platform/cloud/oauth/oauthApi.ts new file mode 100644 index 0000000000..950a0a76ea --- /dev/null +++ b/src/platform/cloud/oauth/oauthApi.ts @@ -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 + +export class OAuthApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'OAuthApiError' + } +} + +async function readErrorMessage(response: Response): Promise { + 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 + 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 + 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 { + 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 { + 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 +} diff --git a/src/platform/cloud/oauth/oauthState.test.ts b/src/platform/cloud/oauth/oauthState.test.ts new file mode 100644 index 0000000000..1817d98bfd --- /dev/null +++ b/src/platform/cloud/oauth/oauthState.test.ts @@ -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() + }) +}) diff --git a/src/platform/cloud/oauth/oauthState.ts b/src/platform/cloud/oauth/oauthState.ts new file mode 100644 index 0000000000..b4f101c02c --- /dev/null +++ b/src/platform/cloud/oauth/oauthState.ts @@ -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) +} diff --git a/src/platform/cloud/oauth/useOAuthPostLoginRedirect.test.ts b/src/platform/cloud/oauth/useOAuthPostLoginRedirect.test.ts new file mode 100644 index 0000000000..970749691b --- /dev/null +++ b/src/platform/cloud/oauth/useOAuthPostLoginRedirect.test.ts @@ -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 | 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() + }) +}) diff --git a/src/platform/cloud/oauth/useOAuthPostLoginRedirect.ts b/src/platform/cloud/oauth/useOAuthPostLoginRedirect.ts new file mode 100644 index 0000000000..2371e0c38d --- /dev/null +++ b/src/platform/cloud/oauth/useOAuthPostLoginRedirect.ts @@ -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 { + 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 } +} diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue index ce0ec8f790..04c5bace88 100644 --- a/src/platform/cloud/onboarding/CloudLoginView.vue +++ b/src/platform/cloud/onboarding/CloudLoginView.vue @@ -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() } } diff --git a/src/platform/cloud/onboarding/CloudSignupView.vue b/src/platform/cloud/onboarding/CloudSignupView.vue index 65fd210277..e22d939914 100644 --- a/src/platform/cloud/onboarding/CloudSignupView.vue +++ b/src/platform/cloud/onboarding/CloudSignupView.vue @@ -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() } } diff --git a/src/platform/cloud/onboarding/composables/usePostAuthRedirect.ts b/src/platform/cloud/onboarding/composables/usePostAuthRedirect.ts new file mode 100644 index 0000000000..19b768f1e1 --- /dev/null +++ b/src/platform/cloud/onboarding/composables/usePostAuthRedirect.ts @@ -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 + 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 } +} diff --git a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts index e7d957b33d..8c7636174c 100644 --- a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts +++ b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts @@ -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', diff --git a/src/platform/navigation/preservedQueryNamespaces.ts b/src/platform/navigation/preservedQueryNamespaces.ts index 8501f30519..87ccc4f8c8 100644 --- a/src/platform/navigation/preservedQueryNamespaces.ts +++ b/src/platform/navigation/preservedQueryNamespaces.ts @@ -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 diff --git a/src/router.ts b/src/router.ts index 7623786565..b653140d69 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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' ]) diff --git a/vite.config.mts b/vite.config.mts index a95d5e4dc5..795c043807 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -214,6 +214,11 @@ export default defineConfig({ } }, + '/oauth': { + target: DEV_SERVER_COMFYUI_URL, + ...cloudProxyConfig + }, + '/ws': { target: DEV_SERVER_COMFYUI_URL, ws: true,