Compare commits

...

9 Commits

Author SHA1 Message Date
GitHub Action
baa95a4c4e [automated] Apply ESLint and Oxfmt fixes 2026-07-08 18:47:20 +00:00
Hunter Senft-Grupp
da410b633a refactor(auth): extract token mint mock, parameterize tests, trim comments 2026-07-08 18:43:27 +00:00
Hunter Senft-Grupp
99a6f1128b test(auth): mock workspace token mint in cloud e2e boots 2026-07-08 16:24:52 +00:00
GitHub Action
8accc836ee [automated] Apply ESLint and Oxfmt fixes 2026-07-08 15:53:28 +00:00
Hunter Senft-Grupp
b68ac2f9fe fix(auth): make isPermanentRecoveryFailure a type guard to satisfy tsc 2026-07-08 15:49:24 +00:00
Hunter Senft-Grupp
a9d06e543a test(auth): lock in burst coalescing; note unified reconciliation gap 2026-07-08 07:23:50 +00:00
Hunter Senft-Grupp
cb19d54eb1 fix(auth): reconcile revoked workspace, harden recovery lifecycle 2026-07-08 07:11:37 +00:00
Hunter Senft-Grupp
6850877438 fix(auth): harden workspace token recovery (expiry gate, getAuthToken, backoff, teardown) 2026-07-08 06:25:34 +00:00
Hunter Senft-Grupp
60d917807e fix(auth): recover workspace token instead of downgrading to personal identity 2026-07-08 06:07:13 +00:00
10 changed files with 664 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

View File

@@ -33,6 +33,27 @@ export function member(
}
}
/**
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
* this the mint fails and auth cannot resolve the active workspace.
*/
export async function mockWorkspaceTokenMint(
page: Page,
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
) {
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
@@ -46,17 +67,7 @@ export async function mockWorkspace(
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await mockWorkspaceTokenMint(page, ws)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({

View File

@@ -11,6 +11,10 @@ import type {
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* Billing facade consumers — FE-933 (B3) regression.
@@ -81,6 +85,7 @@ async function mockCloudBoot(
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -360,6 +360,35 @@ describe('useTeamWorkspaceStore', () => {
})
})
describe('forgetRevokedActiveWorkspace', () => {
it.for([
{ type: 'team', workspace: mockTeamWorkspace, reloads: 1 },
{ type: 'personal', workspace: mockPersonalWorkspace, reloads: 0 }
])(
'reloads $reloads time(s) when the active $type workspace is revoked',
async ({ workspace, reloads }) => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = workspace.id
store.forgetRevokedActiveWorkspace(workspace.id)
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(reloads)
expect(mockReload).toHaveBeenCalledTimes(reloads)
}
)
it('is a no-op when the workspace is not the active one', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.activeWorkspaceId = mockTeamWorkspace.id
store.forgetRevokedActiveWorkspace('some-other-workspace')
expect(mockReload).not.toHaveBeenCalled()
})
})
describe('createWorkspace', () => {
it('creates workspace and triggers reload', async () => {
const newWorkspace = {

View File

@@ -103,6 +103,14 @@ function setLastWorkspaceId(workspaceId: string): void {
}
}
function clearLastWorkspaceId(): void {
try {
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
console.warn('Failed to clear last workspace ID from localStorage')
}
}
const MAX_OWNED_WORKSPACES = 10
export const MAX_WORKSPACE_MEMBERS = 30
const MAX_INIT_RETRIES = 3
@@ -362,6 +370,20 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
}
}
/**
* Drop a revoked/deleted active workspace and reload so init falls back to the
* personal workspace. Skips the personal workspace to avoid a reload loop.
*/
function forgetRevokedActiveWorkspace(workspaceId: string): void {
if (activeWorkspaceId.value !== workspaceId) return
const revoked = workspaces.value.find((w) => w.id === workspaceId)
if (revoked?.type === 'personal') return
clearLastWorkspaceId()
window.location.reload()
}
/**
* Switch to a different workspace.
* Clears workspace context and reloads the page.
@@ -744,6 +766,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Workspace Actions
switchWorkspace,
forgetRevokedActiveWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,

View File

@@ -11,11 +11,24 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
const mockGetIdToken = vi.fn()
const mockNotifyTokenRefreshed = vi.fn()
const mockToastAdd = vi.fn()
const mockCurrentUser = vi.hoisted((): { value: { uid: string } | null } => ({
value: null
}))
const mockForgetRevokedActiveWorkspace = vi.fn()
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
notifyTokenRefreshed: mockNotifyTokenRefreshed
notifyTokenRefreshed: mockNotifyTokenRefreshed,
get currentUser() {
return mockCurrentUser.value
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
forgetRevokedActiveWorkspace: mockForgetRevokedActiveWorkspace
})
}))
@@ -82,6 +95,7 @@ describe('useWorkspaceAuthStore', () => {
sessionStorage.clear()
mockTeamWorkspacesEnabled.value = true
mockUnifiedCloudAuthEnabled.value = false
mockCurrentUser.value = null
})
afterEach(() => {
@@ -583,6 +597,301 @@ describe('useWorkspaceAuthStore', () => {
})
})
describe('ensureWorkspaceAuthHeader', () => {
it('returns the existing header without minting when the token is valid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('mints a token for the preferred workspace when none exists', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
})
it('coalesces concurrent recovery onto a single mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const [first, second] = await Promise.all([
store.ensureWorkspaceAuthHeader('workspace-123'),
store.ensureWorkspaceAuthHeader('workspace-123')
])
expect(first).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(second).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('awaits an in-flight switch instead of racing a second mint', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveResponse: (value: unknown) => void = () => {}
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve
})
const mockFetch = vi.fn().mockReturnValue(responsePromise)
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-123')
const ensurePromise = store.ensureWorkspaceAuthHeader('workspace-123')
resolveResponse({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await switchPromise
const header = await ensurePromise
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('returns null (never a downgrade) when recovery fails transiently', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
})
it('returns null when there is no workspace to recover to', async () => {
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader()
expect(header).toBeNull()
})
it('is a no-op returning null when the feature flag is disabled', async () => {
mockTeamWorkspacesEnabled.value = false
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
expect(header).toBeNull()
expect(mockFetch).not.toHaveBeenCalled()
})
it('tears down workspace context and surfaces a toast on a permanent recovery failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value).toBeNull()
expect(mockToastAdd).toHaveBeenCalledTimes(1)
})
it('backs off re-minting after a failed recovery instead of retrying every call', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const first = await store.ensureWorkspaceToken('workspace-123')
const second = await store.ensureWorkspaceToken('workspace-123')
expect(first).toBeNull()
expect(second).toBeNull()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('forgets the revoked active workspace on a permanent workspace-selection failure', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).toHaveBeenCalledWith(
'workspace-123'
)
})
it('does not forget the workspace when the failure is an auth error, not revocation', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
const store = useWorkspaceAuthStore()
const token = await store.ensureWorkspaceToken('workspace-123')
expect(token).toBeNull()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('preserves a valid context on a transient Firebase network failure while signed in', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
mockCurrentUser.value = { uid: 'user-1' }
mockGetIdToken.mockResolvedValue(undefined)
const token = await store.ensureWorkspaceToken('workspace-999')
expect(token).toBeNull()
expect(currentWorkspace.value?.id).toBe('workspace-123')
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
})
it('collapses a burst of waiters into a single mint after a shared in-flight switch rejects', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({})
})
.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const initialSwitch = store
.switchWorkspace('workspace-123')
.catch(() => {})
const [first, second] = await Promise.all([
store.ensureWorkspaceToken('workspace-123'),
store.ensureWorkspaceToken('workspace-123')
])
await initialSwitch
expect(first).toBe('workspace-token-abc')
expect(second).toBe('workspace-token-abc')
// One failed initial switch + exactly one recovery mint the burst shares.
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('re-mints for the requested workspace rather than returning an in-flight switch to a different one', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockImplementation((_url, options) => {
const { workspace_id: workspaceId } = JSON.parse(options.body)
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: `token-${workspaceId}`,
workspace: { ...mockWorkspace, id: workspaceId }
})
})
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const switchPromise = store.switchWorkspace('workspace-other')
const token = await store.ensureWorkspaceToken('workspace-123')
await switchPromise
expect(token).toBe('token-workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
describe('token refresh scheduling', () => {
it('schedules token refresh 5 minutes before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')

View File

@@ -8,6 +8,7 @@ import {
TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
@@ -40,6 +41,8 @@ export type WorkspaceTokenResponse = z.infer<
const MAX_SCHEDULED_REFRESH_RETRIES = 3
const RECOVERY_COOLDOWN_MS = 5000
export class WorkspaceAuthError extends Error {
constructor(
message: string,
@@ -112,6 +115,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
// Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
let inFlightSwitchCount = 0
let inFlightSwitchPromise: Promise<void> | null = null
let recoveryCooldownUntil = 0
let scheduledRefreshRetryCount = 0
// The unified lifecycle keeps its own timer + request-id so it never shares
// mutable state with the legacy switchWorkspace/refreshToken machinery.
@@ -371,7 +376,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
async function switchWorkspace(workspaceId: string): Promise<void> {
async function performSwitchWorkspace(workspaceId: string): Promise<void> {
if (!flags.teamWorkspacesEnabled) {
return
}
@@ -399,6 +404,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
workspaceToken.value = token
workspaceTokenExpiresAt.value = expiresAt
scheduledRefreshRetryCount = 0
recoveryCooldownUntil = 0
persistToSession(workspace, token, expiresAt)
scheduleTokenRefresh(expiresAt)
@@ -419,6 +425,124 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
function switchWorkspace(workspaceId: string): Promise<void> {
const promise = performSwitchWorkspace(workspaceId)
inFlightSwitchPromise = promise
void promise
.catch(() => {})
.finally(() => {
if (inFlightSwitchPromise === promise) {
inFlightSwitchPromise = null
}
})
return promise
}
function hasValidTokenForWorkspace(workspaceId: string | undefined): boolean {
return (
hasValidWorkspaceToken() &&
(workspaceId === undefined || currentWorkspace.value?.id === workspaceId)
)
}
// NOT_AUTHENTICATED while still signed in is a transient network failure, not
// a revoked session, so it must not tear down a valid context.
function isPermanentRecoveryFailure(err: unknown): err is WorkspaceAuthError {
if (!isPermanentAuthError(err)) {
return false
}
if (err.code === 'NOT_AUTHENTICATED' && useAuthStore().currentUser) {
return false
}
return true
}
// The workspace selection itself is invalid (revoked or deleted), so
// abandoning it can help — unlike a plain auth failure.
function isWorkspaceSelectionInvalid(
err: unknown
): err is WorkspaceAuthError {
return (
err instanceof WorkspaceAuthError &&
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
)
}
function startRecoveryCooldown(): void {
recoveryCooldownUntil = Date.now() + RECOVERY_COOLDOWN_MS
}
function handleRecoveryFailure(
err: unknown,
failedWorkspaceId?: string
): void {
if (isPermanentRecoveryFailure(err)) {
const hadContext = currentWorkspace.value !== null
clearWorkspaceContext()
if (hadContext) {
surfacePermanentAuthError(err)
}
if (failedWorkspaceId && isWorkspaceSelectionInvalid(err)) {
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(failedWorkspaceId)
}
}
startRecoveryCooldown()
console.warn('Workspace auth recovery failed:', err)
}
/**
* Resolve a valid workspace token, minting one if needed. Coalesces a burst of
* callers onto a single in-flight mint, backs off after failure, and returns
* null so callers fail closed rather than downgrade to the personal identity.
*/
async function ensureWorkspaceToken(
preferredWorkspaceId?: string
): Promise<string | null> {
if (!flags.teamWorkspacesEnabled) {
return null
}
const targetWorkspaceId = preferredWorkspaceId ?? currentWorkspace.value?.id
while (true) {
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
return workspaceToken.value
}
// Join any in-flight mint and re-check rather than launching our own.
if (inFlightSwitchPromise) {
await inFlightSwitchPromise.catch(() => {})
continue
}
if (!targetWorkspaceId || Date.now() < recoveryCooldownUntil) {
return null
}
try {
await switchWorkspace(targetWorkspaceId)
} catch (err) {
handleRecoveryFailure(err, targetWorkspaceId)
return null
}
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
return workspaceToken.value
}
// Resolved without a usable token; back off like a failure and fail closed.
startRecoveryCooldown()
return null
}
}
async function ensureWorkspaceAuthHeader(
preferredWorkspaceId?: string
): Promise<AuthHeader | null> {
const token = await ensureWorkspaceToken(preferredWorkspaceId)
return token ? { Authorization: `Bearer ${token}` } : null
}
async function refreshToken(): Promise<void> {
if (!currentWorkspace.value) {
return
@@ -457,6 +581,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
if (!isStaleWorkspaceRequest(capturedRequestId)) {
console.error('Workspace access revoked or auth invalid:', err)
clearWorkspaceContext()
if (isWorkspaceSelectionInvalid(err)) {
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(workspaceId)
}
}
return
}
@@ -500,6 +627,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
//
// A parallel mint/refresh lifecycle that writes to the dormant `unifiedToken`
// slot. The legacy switchWorkspace/refreshToken machinery above is untouched.
//
// TODO(unified): call forgetRevokedActiveWorkspace on
// ACCESS_DENIED/WORKSPACE_NOT_FOUND here too, with the PR-3 consumer flip.
// Mint body the unified session re-mints against: `{}` = personal (resolved
// server-side from the Firebase identity), `{ workspace_id }` = explicit.
@@ -663,6 +793,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
workspaceToken.value = null
workspaceTokenExpiresAt.value = null
scheduledRefreshRetryCount = 0
recoveryCooldownUntil = 0
inFlightSwitchPromise = null
error.value = null
clearSessionStorage()
clearUnifiedContext()
@@ -688,6 +820,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
mintAtLogin,
remintUnifiedOnce,
getWorkspaceAuthHeader,
ensureWorkspaceAuthHeader,
ensureWorkspaceToken,
getWorkspaceToken,
clearWorkspaceContext
}

View File

@@ -12,6 +12,8 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useDialogService } from '@/services/dialogService'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
@@ -621,6 +623,114 @@ describe('useAuthStore', () => {
})
})
describe('getAuthHeader workspace recovery', () => {
beforeEach(() => {
mockFeatureFlags.teamWorkspacesEnabled = true
})
it('uses the workspace header when a valid workspace token exists', async () => {
const workspaceAuth = useWorkspaceAuthStore()
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue({
Authorization: 'Bearer ws-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({ Authorization: 'Bearer ws-token' })
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('recovers the workspace token instead of downgrading to personal auth', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
const ensureSpy = vi
.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
.mockResolvedValue({ Authorization: 'Bearer recovered-ws-token' })
const header = await store.getAuthHeader()
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
expect(header).toEqual({ Authorization: 'Bearer recovered-ws-token' })
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader').mockResolvedValue(
null
)
const header = await store.getAuthHeader()
expect(header).toBeNull()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = null
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
const header = await store.getAuthHeader()
expect(ensureSpy).not.toHaveBeenCalled()
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
})
})
describe('getAuthToken workspace recovery', () => {
beforeEach(() => {
mockFeatureFlags.teamWorkspacesEnabled = true
})
it('recovers the workspace token instead of downgrading to personal auth', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
const ensureSpy = vi
.spyOn(workspaceAuth, 'ensureWorkspaceToken')
.mockResolvedValue('recovered-ws-token')
const token = await store.getAuthToken()
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
expect(token).toBe('recovered-ws-token')
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = 'workspace-123'
vi.spyOn(workspaceAuth, 'ensureWorkspaceToken').mockResolvedValue(null)
const token = await store.getAuthToken()
expect(token).toBeUndefined()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
const workspaceAuth = useWorkspaceAuthStore()
const teamStore = useTeamWorkspaceStore()
teamStore.activeWorkspaceId = null
vi.spyOn(workspaceAuth, 'getWorkspaceToken').mockReturnValue(undefined)
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceToken')
const token = await store.getAuthToken()
expect(ensureSpy).not.toHaveBeenCalled()
expect(token).toBe('mock-id-token')
})
})
describe('social authentication', () => {
describe('loginWithGoogle', () => {
it('should sign in with Google', async () => {

View File

@@ -31,6 +31,7 @@ import {
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
@@ -226,7 +227,16 @@ export const useAuthStore = defineStore('auth', () => {
}
if (flags.teamWorkspacesEnabled) {
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
const workspaceAuth = useWorkspaceAuthStore()
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
// Recover the workspace token rather than downgrade to the personal
// identity, which is what makes cloud requests oscillate.
if (activeWorkspaceId) {
return workspaceAuth.ensureWorkspaceAuthHeader(activeWorkspaceId)
}
const wsHeader = workspaceAuth.getWorkspaceAuthHeader()
if (wsHeader) return wsHeader
}
@@ -261,7 +271,18 @@ export const useAuthStore = defineStore('auth', () => {
}
if (flags.teamWorkspacesEnabled) {
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
const workspaceAuth = useWorkspaceAuthStore()
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
// Mirror getAuthHeader for WebSocket/queue auth.
if (activeWorkspaceId) {
return (
(await workspaceAuth.ensureWorkspaceToken(activeWorkspaceId)) ??
undefined
)
}
const wsToken = workspaceAuth.getWorkspaceToken()
if (wsToken) return wsToken
}