mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
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.
This commit is contained in:
@@ -2129,6 +2129,19 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Allow",
|
||||
"deny": "Deny",
|
||||
"genericError": "OAuth request failed. Please restart from the client app.",
|
||||
"loading": "Loading OAuth request...",
|
||||
"missingRequest": "This OAuth request is missing. Please restart from the client app.",
|
||||
"noWorkspaces": "No eligible workspaces are available for this request.",
|
||||
"resourceLabel": "Authorize this app",
|
||||
"scopesTitle": "Requested access",
|
||||
"workspaceTitle": "Choose workspace"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
|
||||
84
src/platform/auth/session/useSessionCookie.test.ts
Normal file
84
src/platform/auth/session/useSessionCookie.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockGetIdToken = vi.fn()
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
teamWorkspacesEnabled: true
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
getIdToken: mockGetIdToken,
|
||||
getAuthHeader: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useSessionCookie', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
mockGetIdToken.mockReset()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it('createSessionOrThrow posts the Firebase token and awaits success', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-id-token')
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
new Response(null, { status: 204 })
|
||||
)
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
|
||||
await useSessionCookie().createSessionOrThrow()
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/session', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: 'Bearer firebase-id-token',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('createSessionOrThrow fails fast without a Firebase token', async () => {
|
||||
mockGetIdToken.mockResolvedValue(null)
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
|
||||
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
|
||||
'No Firebase token available for session creation'
|
||||
)
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('createSessionOrThrow fails fast on non-success responses', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-id-token')
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'session denied' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
|
||||
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
|
||||
'session denied'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -8,6 +9,35 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
* Creates and deletes session cookies on the ComfyUI server.
|
||||
*/
|
||||
export const useSessionCookie = () => {
|
||||
const createSessionWithHeader = async (
|
||||
authHeader: Record<string, string>
|
||||
): Promise<Response> => {
|
||||
return await fetch(api.apiURL('/auth/session'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const readSessionError = async (response: Response): Promise<string> => {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return errorData.message || response.statusText
|
||||
}
|
||||
|
||||
const getFirebaseSessionHeaderOrThrow = async (): Promise<
|
||||
Record<string, string>
|
||||
> => {
|
||||
const firebaseToken = await useAuthStore().getIdToken()
|
||||
if (!firebaseToken) {
|
||||
throw new Error('No Firebase token available for session creation')
|
||||
}
|
||||
|
||||
return { Authorization: `Bearer ${firebaseToken}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or refreshes the session cookie.
|
||||
* Called after login and on token refresh.
|
||||
@@ -47,20 +77,12 @@ export const useSessionCookie = () => {
|
||||
authHeader = header
|
||||
}
|
||||
|
||||
const response = await fetch(api.apiURL('/auth/session'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const response = await createSessionWithHeader(authHeader)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
console.warn(
|
||||
'Failed to create session cookie:',
|
||||
errorData.message || response.statusText
|
||||
await readSessionError(response)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -68,11 +90,22 @@ export const useSessionCookie = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const createSessionOrThrow = async (): Promise<void> => {
|
||||
if (!isCloud) return
|
||||
|
||||
const authHeader = await getFirebaseSessionHeaderOrThrow()
|
||||
const response = await createSessionWithHeader(authHeader)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readSessionError(response))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the session cookie.
|
||||
* Called on logout.
|
||||
*/
|
||||
const deleteSession = async (): Promise<void> => {
|
||||
clearOAuthRequestId()
|
||||
if (!isCloud) return
|
||||
|
||||
try {
|
||||
@@ -95,6 +128,7 @@ export const useSessionCookie = () => {
|
||||
|
||||
return {
|
||||
createSession,
|
||||
createSessionOrThrow,
|
||||
deleteSession
|
||||
}
|
||||
}
|
||||
|
||||
41
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal file
41
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
|
||||
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
|
||||
|
||||
const previewChallenge: OAuthConsentChallenge = {
|
||||
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrf_token: 'preview-csrf-token',
|
||||
client_display_name: 'Cursor',
|
||||
resource_display_name: 'ComfyUI MCP',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
|
||||
workspaces: [
|
||||
{
|
||||
id: 'personal-workspace',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
},
|
||||
{
|
||||
id: 'team-workspace',
|
||||
name: 'Team Workspace',
|
||||
type: 'team',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const meta: Meta<typeof OAuthConsentView> = {
|
||||
title: 'Cloud/OAuth/Consent',
|
||||
component: OAuthConsentView
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AllMcpScopes: Story = {
|
||||
args: {
|
||||
initialChallenge: previewChallenge,
|
||||
submitDecision: async () => {}
|
||||
}
|
||||
}
|
||||
136
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal file
136
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
|
||||
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
oauth: {
|
||||
consent: {
|
||||
allow: 'Allow',
|
||||
deny: 'Deny',
|
||||
genericError: 'OAuth request failed.',
|
||||
loading: 'Loading OAuth request...',
|
||||
missingRequest: 'This OAuth request is missing.',
|
||||
noWorkspaces: 'No eligible workspaces are available.',
|
||||
resourceLabel: 'Authorize this app',
|
||||
scopesTitle: 'Requested access',
|
||||
workspaceTitle: 'Choose workspace'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const challenge: OAuthConsentChallenge = {
|
||||
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrf_token: 'csrf-token',
|
||||
client_display_name: 'Cursor',
|
||||
resource_display_name: 'ComfyUI MCP',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
|
||||
workspaces: [
|
||||
{
|
||||
id: 'personal-workspace',
|
||||
name: 'Personal',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
},
|
||||
{
|
||||
id: 'team-workspace',
|
||||
name: 'Team',
|
||||
type: 'team',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const renderConsent = (
|
||||
overrides: Partial<OAuthConsentChallenge> = {},
|
||||
submitDecision = vi.fn()
|
||||
) =>
|
||||
render(OAuthConsentView, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
initialChallenge: { ...challenge, ...overrides },
|
||||
submitDecision
|
||||
}
|
||||
})
|
||||
|
||||
describe('OAuthConsentView', () => {
|
||||
it('renders client, resource, and scopes from the challenge', () => {
|
||||
renderConsent()
|
||||
|
||||
expect(screen.getByText('Cursor')).toBeVisible()
|
||||
expect(screen.getByText('ComfyUI MCP')).toBeVisible()
|
||||
expect(screen.getByText('mcp:tools:read')).toBeVisible()
|
||||
expect(screen.getByText('mcp:tools:call')).toBeVisible()
|
||||
expect(screen.getByText('mcp:unknown:test')).toBeVisible()
|
||||
})
|
||||
|
||||
it('requires workspace selection when multiple workspaces are available', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitDecision = vi.fn()
|
||||
renderConsent({}, submitDecision)
|
||||
|
||||
const allow = screen.getByRole('button', { name: 'Allow' })
|
||||
expect(allow).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByLabelText(/Team/))
|
||||
expect(allow).toBeEnabled()
|
||||
|
||||
await user.click(allow)
|
||||
|
||||
expect(submitDecision).toHaveBeenCalledWith({
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'allow',
|
||||
workspaceId: 'team-workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a single workspace read-only without auto-submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitDecision = vi.fn()
|
||||
renderConsent(
|
||||
{
|
||||
workspaces: [challenge.workspaces[0]]
|
||||
},
|
||||
submitDecision
|
||||
)
|
||||
|
||||
expect(screen.getByText('Personal')).toBeVisible()
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
|
||||
expect(submitDecision).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Allow' }))
|
||||
|
||||
expect(submitDecision).toHaveBeenCalledWith({
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits deny with the selected workspace', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitDecision = vi.fn()
|
||||
renderConsent({}, submitDecision)
|
||||
|
||||
await user.click(screen.getByLabelText(/Team/))
|
||||
await user.click(screen.getByRole('button', { name: 'Deny' }))
|
||||
|
||||
expect(submitDecision).toHaveBeenCalledWith({
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'deny',
|
||||
workspaceId: 'team-workspace'
|
||||
})
|
||||
})
|
||||
})
|
||||
202
src/platform/cloud/oauth/OAuthConsentView.vue
Normal file
202
src/platform/cloud/oauth/OAuthConsentView.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<main class="mx-auto flex max-w-2xl flex-col gap-6 p-8">
|
||||
<section v-if="challenge" class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0 text-sm text-muted">
|
||||
{{ t('oauth.consent.resourceLabel') }}
|
||||
</p>
|
||||
<h1 class="m-0 text-2xl font-semibold">
|
||||
{{ challenge.client_display_name }}
|
||||
</h1>
|
||||
<p v-if="challenge.resource_display_name" class="m-0 text-muted">
|
||||
{{ challenge.resource_display_name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="rounded-lg bg-(--p-content-background) p-4">
|
||||
<h2 class="mt-0 text-base font-semibold">
|
||||
{{ t('oauth.consent.scopesTitle') }}
|
||||
</h2>
|
||||
<ul class="mb-0 flex flex-col gap-2 pl-5">
|
||||
<li v-for="scope in challenge.scopes" :key="scope">
|
||||
{{ labelForScope(scope) }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg bg-(--p-content-background) p-4">
|
||||
<h2 class="mt-0 text-base font-semibold">
|
||||
{{ t('oauth.consent.workspaceTitle') }}
|
||||
</h2>
|
||||
<p v-if="challenge.workspaces.length === 0" class="text-muted">
|
||||
{{ t('oauth.consent.noWorkspaces') }}
|
||||
</p>
|
||||
<div v-else-if="challenge.workspaces.length === 1">
|
||||
<WorkspaceSummary :workspace="challenge.workspaces[0]" />
|
||||
</div>
|
||||
<fieldset v-else class="m-0 flex flex-col gap-3 border-0 p-0">
|
||||
<legend class="sr-only">
|
||||
{{ t('oauth.consent.workspaceTitle') }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="workspace in challenge.workspaces"
|
||||
:key="workspace.id"
|
||||
class="flex cursor-pointer gap-3 rounded-md border border-solid border-muted p-3"
|
||||
>
|
||||
<input
|
||||
v-model="selectedWorkspaceId"
|
||||
type="radio"
|
||||
name="workspace"
|
||||
:value="workspace.id"
|
||||
/>
|
||||
<WorkspaceSummary :workspace />
|
||||
</label>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" role="alert" class="m-0 text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="rounded-md bg-neutral-700 px-4 py-2 text-white disabled:opacity-50"
|
||||
:disabled="isSubmitting || !canSubmit"
|
||||
@click="submit('deny')"
|
||||
>
|
||||
{{ t('oauth.consent.deny') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
|
||||
:disabled="isSubmitting || !canSubmit"
|
||||
@click="submit('allow')"
|
||||
>
|
||||
{{ t('oauth.consent.allow') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-else-if="errorMessage" role="alert" class="m-0 text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-muted">
|
||||
{{ t('oauth.consent.loading') }}
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchOAuthConsentChallenge,
|
||||
submitOAuthConsentDecision
|
||||
} from '@/platform/cloud/oauth/oauthApi'
|
||||
import type {
|
||||
OAuthConsentChallenge,
|
||||
OAuthConsentDecision,
|
||||
OAuthWorkspace
|
||||
} from '@/platform/cloud/oauth/oauthApi'
|
||||
import {
|
||||
clearOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
|
||||
defineProps<{
|
||||
initialChallenge?: OAuthConsentChallenge
|
||||
submitDecision?: OAuthConsentDecision
|
||||
}>()
|
||||
|
||||
const WorkspaceSummary = (props: { workspace: OAuthWorkspace }) =>
|
||||
h('span', { class: 'flex flex-col gap-1' }, [
|
||||
h('span', props.workspace.name),
|
||||
h('span', { class: 'text-xs text-muted' }, [
|
||||
props.workspace.type,
|
||||
' · ',
|
||||
props.workspace.role
|
||||
])
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
|
||||
const selectedWorkspaceId = ref(
|
||||
initialChallenge?.workspaces.length === 1
|
||||
? initialChallenge.workspaces[0].id
|
||||
: ''
|
||||
)
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const selectedWorkspaceIsValid = computed(() => {
|
||||
return Boolean(
|
||||
challenge.value?.workspaces.some(
|
||||
(workspace) => workspace.id === selectedWorkspaceId.value
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => selectedWorkspaceIsValid.value)
|
||||
|
||||
function labelForScope(scope: string): string {
|
||||
return scope
|
||||
}
|
||||
|
||||
function requestIdFromRoute(): string | null {
|
||||
return typeof route.query.oauth_request_id === 'string'
|
||||
? route.query.oauth_request_id
|
||||
: getOAuthRequestId()
|
||||
}
|
||||
|
||||
function initializeWorkspaceSelection(nextChallenge: OAuthConsentChallenge) {
|
||||
selectedWorkspaceId.value =
|
||||
nextChallenge.workspaces.length === 1 ? nextChallenge.workspaces[0].id : ''
|
||||
}
|
||||
|
||||
async function loadChallenge() {
|
||||
const oauthRequestId = requestIdFromRoute()
|
||||
if (!oauthRequestId) {
|
||||
errorMessage.value = t('oauth.consent.missingRequest')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const nextChallenge = await fetchOAuthConsentChallenge(oauthRequestId)
|
||||
challenge.value = nextChallenge
|
||||
initializeWorkspaceSelection(nextChallenge)
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : t('oauth.consent.genericError')
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(decision: 'allow' | 'deny') {
|
||||
if (!challenge.value || !selectedWorkspaceIsValid.value) return
|
||||
|
||||
errorMessage.value = ''
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await submitDecision({
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId: selectedWorkspaceId.value
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : t('oauth.consent.genericError')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!initialChallenge) {
|
||||
void loadChallenge()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
125
src/platform/cloud/oauth/oauthApi.ts
Normal file
125
src/platform/cloud/oauth/oauthApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export type OAuthWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'personal' | 'team'
|
||||
role: 'owner' | 'member'
|
||||
}
|
||||
|
||||
export type OAuthConsentChallenge = {
|
||||
oauth_request_id: string
|
||||
csrf_token: string
|
||||
client_display_name: string
|
||||
resource_display_name?: string
|
||||
scopes: string[]
|
||||
workspaces: OAuthWorkspace[]
|
||||
}
|
||||
|
||||
export type OAuthConsentDecisionParams = {
|
||||
oauthRequestId: string
|
||||
csrfToken: string
|
||||
decision: 'allow' | 'deny'
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export type OAuthConsentDecision = (
|
||||
params: OAuthConsentDecisionParams
|
||||
) => Promise<void>
|
||||
|
||||
type OAuthDecisionResponse = {
|
||||
redirect_url?: string
|
||||
}
|
||||
|
||||
export class OAuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OAuthApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function getOAuthOrigin(): string {
|
||||
return import.meta.env.VITE_CLOUD_INGEST_ORIGIN ?? ''
|
||||
}
|
||||
|
||||
function oauthUrl(path: string): string {
|
||||
const origin = getOAuthOrigin()
|
||||
return origin ? new URL(path, origin).toString() : path
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => null)
|
||||
return body?.message ?? response.statusText
|
||||
}
|
||||
|
||||
function assertChallenge(
|
||||
value: unknown
|
||||
): asserts value is OAuthConsentChallenge {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('OAuth consent challenge is invalid')
|
||||
}
|
||||
|
||||
const challenge = value as Partial<OAuthConsentChallenge>
|
||||
if (
|
||||
typeof challenge.oauth_request_id !== 'string' ||
|
||||
typeof challenge.csrf_token !== 'string' ||
|
||||
typeof challenge.client_display_name !== 'string' ||
|
||||
!Array.isArray(challenge.scopes) ||
|
||||
!Array.isArray(challenge.workspaces)
|
||||
) {
|
||||
throw new Error('OAuth consent challenge is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOAuthConsentChallenge(
|
||||
oauthRequestId: string
|
||||
): Promise<OAuthConsentChallenge> {
|
||||
const response = await fetch(
|
||||
oauthUrl(`/oauth/authorize?oauth_request_id=${oauthRequestId}`),
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OAuthApiError(await readErrorMessage(response), response.status)
|
||||
}
|
||||
|
||||
const challenge: unknown = await response.json()
|
||||
assertChallenge(challenge)
|
||||
return challenge
|
||||
}
|
||||
|
||||
export async function submitOAuthConsentDecision({
|
||||
oauthRequestId,
|
||||
csrfToken,
|
||||
decision,
|
||||
workspaceId
|
||||
}: OAuthConsentDecisionParams): Promise<void> {
|
||||
const response = await fetch(oauthUrl('/oauth/authorize'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oauth_request_id: oauthRequestId,
|
||||
csrf_token: csrfToken,
|
||||
decision,
|
||||
workspace_id: workspaceId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OAuthApiError(await readErrorMessage(response), response.status)
|
||||
}
|
||||
|
||||
const body: OAuthDecisionResponse = await response.json()
|
||||
if (!body.redirect_url) {
|
||||
throw new Error('OAuth consent response did not include redirect_url')
|
||||
}
|
||||
|
||||
globalThis.location.href = body.redirect_url
|
||||
}
|
||||
53
src/platform/cloud/oauth/oauthState.test.ts
Normal file
53
src/platform/cloud/oauth/oauthState.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId,
|
||||
getOAuthRequestId,
|
||||
hasOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
describe('oauthState', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
clearOAuthRequestId()
|
||||
})
|
||||
|
||||
it('captures a valid oauth_request_id only', () => {
|
||||
captureOAuthRequestId({
|
||||
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
client_id: 'must-not-be-stored'
|
||||
})
|
||||
|
||||
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
|
||||
expect(hasOAuthRequestId()).toBe(true)
|
||||
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBe(
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores missing, repeated, and invalid request ids', () => {
|
||||
captureOAuthRequestId({})
|
||||
expect(getOAuthRequestId()).toBeNull()
|
||||
|
||||
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
|
||||
expect(getOAuthRequestId()).toBeNull()
|
||||
|
||||
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
|
||||
expect(getOAuthRequestId()).toBeNull()
|
||||
})
|
||||
|
||||
it('hydrates from session storage and clears after completion', () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.OAuthRequestId',
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
|
||||
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
|
||||
|
||||
clearOAuthRequestId()
|
||||
|
||||
expect(getOAuthRequestId()).toBeNull()
|
||||
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
|
||||
})
|
||||
})
|
||||
38
src/platform/cloud/oauth/oauthState.ts
Normal file
38
src/platform/cloud/oauth/oauthState.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
const OAUTH_REQUEST_ID_STORAGE_KEY = 'Comfy.OAuthRequestId'
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function readQueryString(value: LocationQuery[string]): string | null {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function isOAuthRequestId(value: string): boolean {
|
||||
return UUID_PATTERN.test(value)
|
||||
}
|
||||
|
||||
function readStoredOAuthRequestId(): string | null {
|
||||
const value = sessionStorage.getItem(OAUTH_REQUEST_ID_STORAGE_KEY)
|
||||
return value && isOAuthRequestId(value) ? value : null
|
||||
}
|
||||
|
||||
export function captureOAuthRequestId(query: LocationQuery): string | null {
|
||||
const value = readQueryString(query.oauth_request_id)
|
||||
if (!value || !isOAuthRequestId(value)) return null
|
||||
|
||||
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function getOAuthRequestId(): string | null {
|
||||
return readStoredOAuthRequestId()
|
||||
}
|
||||
|
||||
export function hasOAuthRequestId(): boolean {
|
||||
return getOAuthRequestId() !== null
|
||||
}
|
||||
|
||||
export function clearOAuthRequestId(): void {
|
||||
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
|
||||
}
|
||||
@@ -116,6 +116,11 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
@@ -127,6 +132,7 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useAuthActions()
|
||||
const sessionCookie = useSessionCookie()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const toastStore = useToastStore()
|
||||
@@ -153,6 +159,17 @@ const onSuccess = async () => {
|
||||
life: 2000
|
||||
})
|
||||
|
||||
captureOAuthRequestId(route.query)
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
if (oauthRequestId) {
|
||||
await sessionCookie.createSessionOrThrow()
|
||||
await router.push({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
|
||||
@@ -141,6 +141,11 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -154,6 +159,7 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useAuthActions()
|
||||
const sessionCookie = useSessionCookie()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const userIsInChina = ref(false)
|
||||
@@ -179,6 +185,17 @@ const onSuccess = async () => {
|
||||
life: 2000
|
||||
})
|
||||
|
||||
captureOAuthRequestId(route.query)
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
if (oauthRequestId) {
|
||||
await sessionCookie.createSessionOrThrow()
|
||||
await router.push({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
function oauthConsentRedirect() {
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
return oauthRequestId
|
||||
? {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
: { name: 'cloud-user-check' }
|
||||
}
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/cloud',
|
||||
@@ -12,6 +27,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
|
||||
beforeEnter: async (to, _from, next) => {
|
||||
captureOAuthRequestId(to.query)
|
||||
// Only redirect if not explicitly switching accounts
|
||||
if (!to.query.switchAccount) {
|
||||
const { useCurrentUser } =
|
||||
@@ -19,9 +35,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
// User is already logged in, redirect to user-check
|
||||
// user-check will handle survey, or main page routing
|
||||
return next({ name: 'cloud-user-check' })
|
||||
return next(oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -33,13 +47,14 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
|
||||
beforeEnter: async (to, _from, next) => {
|
||||
captureOAuthRequestId(to.query)
|
||||
if (!to.query.switchAccount) {
|
||||
const { useCurrentUser } =
|
||||
await import('@/composables/auth/useCurrentUser')
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next({ name: 'cloud-user-check' })
|
||||
return next(oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -58,6 +73,11 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'oauth/consent',
|
||||
name: 'cloud-oauth-consent',
|
||||
component: () => import('@/platform/cloud/oauth/OAuthConsentView.vue')
|
||||
},
|
||||
{
|
||||
path: 'user-check',
|
||||
name: 'cloud-user-check',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
|
||||
'/oauth': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
...cloudProxyConfig
|
||||
},
|
||||
|
||||
'/ws': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user