Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 61 additions & 26 deletions src/platform/auth/session/useSessionCookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { api } from '@/scripts/api'
import { isCloud } from '@/platform/distribution/types'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'

/**
* Tracks the in-flight createSession request to dedupe concurrent calls.
*/
let createSessionInFlight: Promise<void> | null = null
/**
* Tracks the in-flight deleteSession request to dedupe concurrent calls.
*/
let deleteSessionInFlight: Promise<void> | null = null

/**
* Session cookie management for cloud authentication.
* Creates and deletes session cookies on the ComfyUI server.
Expand All @@ -14,27 +23,40 @@ export const useSessionCookie = () => {
const createSession = async (): Promise<void> => {
if (!isCloud) return

const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()

if (!authHeader) {
throw new Error('No auth header available for session creation')
if (createSessionInFlight) {
await createSessionInFlight
return
}

const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
createSessionInFlight = (async () => {
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()

if (!authHeader) {
throw new Error('No auth header available for session creation')
}
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
}
})()

try {
await createSessionInFlight
} finally {
createSessionInFlight = null
}
}

Expand All @@ -45,16 +67,29 @@ export const useSessionCookie = () => {
const deleteSession = async (): Promise<void> => {
if (!isCloud) return

const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
if (deleteSessionInFlight) {
await deleteSessionInFlight
return
}

deleteSessionInFlight = (async () => {
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${errorData.message || response.statusText}`
)
}
})()

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${errorData.message || response.statusText}`
)
try {
await deleteSessionInFlight
} finally {
deleteSessionInFlight = null
}
}

Expand Down
19 changes: 15 additions & 4 deletions src/services/extensionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import { useMenuItemStore } from '@/stores/menuItemStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import type { AuthUserInfo } from '@/types/authTypes'

export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
const keybindingStore = useKeybindingStore()
const { wrapWithErrorHandling } = useErrorHandling()
const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } =
useErrorHandling()

/**
* Loads all extensions from the API into the window in parallel
Expand Down Expand Up @@ -77,22 +79,31 @@ export const useExtensionService = () => {

if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
const handleUserResolved = wrapWithErrorHandlingAsync(
(user: AuthUserInfo) => extension.onAuthUserResolved?.(user, app)
)
onUserResolved((user) => {
void extension.onAuthUserResolved?.(user, app)
void handleUserResolved(user)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: the async wrapper now adds structured error context for auth hooks — we log the extension name and the hook (e.g., onAuthUserResolved) and still route to the shared toast. This makes triage clearer without changing UX.

})
}

if (extension.onAuthTokenRefreshed) {
const { onTokenRefreshed } = useCurrentUser()
const handleTokenRefreshed = wrapWithErrorHandlingAsync(() =>
extension.onAuthTokenRefreshed?.()
)
onTokenRefreshed(() => {
void extension.onAuthTokenRefreshed?.()
void handleTokenRefreshed()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification: the wrapper’s return is intentionally discarded (void handleUserResolved(user)). Extension hooks are void | Promise<void>, so a potential undefined return from the wrapper doesn’t affect behavior.

})
}

if (extension.onAuthUserLogout) {
const { onUserLogout } = useCurrentUser()
const handleUserLogout = wrapWithErrorHandlingAsync(() =>
extension.onAuthUserLogout?.()
)
onUserLogout(() => {
void extension.onAuthUserLogout?.()
void handleUserLogout()
})
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/stores/firebaseAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {

// Token refresh trigger - increments when token is refreshed
const tokenRefreshTrigger = ref(0)
/**
* The user ID for which the initial ID token has been observed.
* When a token changes for the same user, that is a refresh.
*/
const lastTokenUserId = ref<string | null>(null)

// Providers
const googleProvider = new GoogleAuthProvider()
Expand Down Expand Up @@ -93,6 +98,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
}

// Reset balance when auth state changes
balance.value = null
Expand All @@ -102,6 +110,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Listen for token refresh events
onIdTokenChanged(auth, (user) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification: the Pinia store is a singleton for the app’s lifetime, so we register one onIdTokenChanged listener and it doesn’t accumulate in normal runtime. If preferred for tests, we can add an explicit dispose() to call the unsubscribe, but it isn’t necessary for the app.

if (user && isCloud) {
// Skip initial token change
if (lastTokenUserId.value !== user.uid) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification: onIdTokenChanged events are delivered sequentially. We treat the first token for any uid as initialization by setting lastTokenUserId and returning; subsequent events for the same uid increment the trigger. We reset lastTokenUserId on sign‑out in onAuthStateChanged. This avoids spurious increments without introducing races; tests cover this behaviour in tests-ui/tests/store/firebaseAuthStore.test.ts.

lastTokenUserId.value = user.uid
return
}
tokenRefreshTrigger.value++
}
})
Expand Down
110 changes: 110 additions & 0 deletions tests-ui/tests/store/firebaseAuthStore.tokenRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'

vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))

vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn()
}))

vi.mock('firebase/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('firebase/auth')>()
return {
...actual,
onAuthStateChanged: vi.fn(),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}
})

import * as firebaseAuth from 'firebase/auth'
import * as vuefire from 'vuefire'

type MinimalUser = { uid: string }

/** Create a minimal user-like object with a stable uid */
function makeUser(uid: string): MinimalUser {
return { uid }
}

describe('firebaseAuthStore token refresh gating', () => {
let onAuthStateChangedCallback:
| ((user: MinimalUser | null) => void)
| undefined
let onIdTokenChangedCallback: ((user: MinimalUser | null) => void) | undefined
let store: any

beforeEach(async () => {
vi.resetModules()
vi.resetAllMocks()
setActivePinia(createPinia())

const authInstance = {}
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(authInstance as any)

vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation((...args) => {
const callback = args[1] as (user: MinimalUser | null) => void
onAuthStateChangedCallback = callback
return vi.fn()
})

vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation((...args) => {
const callback = args[1] as (user: MinimalUser | null) => void
onIdTokenChangedCallback = callback
return vi.fn()
})

const { useFirebaseAuthStore } = await import('@/stores/firebaseAuthStore')
store = useFirebaseAuthStore()
})

it('skips initial token for a user and increments on subsequent refresh', () => {
const user = makeUser('user-123')

onIdTokenChangedCallback?.(user)
expect(store.tokenRefreshTrigger).toBe(0)

onIdTokenChangedCallback?.(user)
expect(store.tokenRefreshTrigger).toBe(1)
})

it('does not increment when uid changes; increments on next refresh for new user', () => {
const userA = makeUser('user-a')
const userB = makeUser('user-b')

onIdTokenChangedCallback?.(userA)
expect(store.tokenRefreshTrigger).toBe(0)

onIdTokenChangedCallback?.(userB)
expect(store.tokenRefreshTrigger).toBe(0)

onIdTokenChangedCallback?.(userB)
expect(store.tokenRefreshTrigger).toBe(1)
})

it('resets gating after logout; first token after logout is skipped', () => {
const user = makeUser('user-x')

onIdTokenChangedCallback?.(user)
onIdTokenChangedCallback?.(user)
expect(store.tokenRefreshTrigger).toBe(1)

onAuthStateChangedCallback?.(null)

onIdTokenChangedCallback?.(user)
expect(store.tokenRefreshTrigger).toBe(1)

onIdTokenChangedCallback?.(user)
expect(store.tokenRefreshTrigger).toBe(2)
})
})
Loading