Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
46 changes: 44 additions & 2 deletions src/stores/__tests__/authTokenPriority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,24 @@ describe('auth token priority chain', () => {
expect(mockApiKeyGetAuthHeader).not.toHaveBeenCalled()
})

it('getAuthHeader returns null when the unified token is empty and does not fall back', async () => {
it('getAuthHeader returns null when signed out even if the unified token is stale', async () => {
authStateCallback(null)
mockUnifiedToken = null
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })

const header = await store.getAuthHeader()

expect(header).toBeNull()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
expect(mockApiKeyGetAuthHeader).not.toHaveBeenCalled()
})

it('getAuthHeader falls back to the Firebase token when the unified mint fails', async () => {
mockUnifiedToken = null
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })

const header = await store.getAuthHeader()

expect(header).toEqual({ Authorization: 'Bearer firebase-token' })
expect(mockApiKeyGetAuthHeader).not.toHaveBeenCalled()
})

Expand All @@ -315,5 +325,37 @@ describe('auth token priority chain', () => {
expect(token).toBeUndefined()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})

// Regression test for the unified-auth login race: onAuthStateChanged
// used to fire workspaceAuthStore.mintAtLogin() without awaiting it
// (authStore.ts ~L167), in the same callback that flips `isInitialized`
// to true. router.beforeEach (router.ts:177-189) waits only for
// `isInitialized` and then reads getAuthHeader() to decide `isLoggedIn`,
// so it could observe a real, authenticated user with no unified token
// minted yet and redirect them to /cloud/login.
it('getAuthHeader awaits an in-flight unified mint instead of racing it', async () => {
let resolveMint: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveMint = resolve
})
)
authStateCallback({ ...mockUser })
mockUnifiedToken = null

let settled = false
const headerPromise = store.getAuthHeader().then((header) => {
settled = true
return header
})
await Promise.resolve()
expect(settled).toBe(false)

resolveMint(false)
const header = await headerPromise

expect(settled).toBe(true)
expect(header).toEqual({ Authorization: 'Bearer firebase-token' })
})
})
})
28 changes: 23 additions & 5 deletions src/stores/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export const useAuthStore = defineStore('auth', () => {
let customerRecovery: Promise<void> | null = null
let customerRecoveryUid: string | undefined
const isFetchingBalance = ref(false)
/**
* The in-flight `mintAtLogin()` call, if any, so `getAuthHeader` can await
* it instead of racing it (see onAuthStateChanged below).
*/
let pendingUnifiedMint: Promise<boolean> | null = null

// Balance state
const balance = ref<GetCustomerBalanceResponse | null>(null)
Expand Down Expand Up @@ -161,10 +166,16 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
pendingUnifiedMint = null
} else if (isCloud) {
// Mint the single Cloud JWT at login (flag-guarded inside the store; a
// no-op when unified_cloud_auth is off).
void useWorkspaceAuthStore().mintAtLogin()
// no-op when unified_cloud_auth is off). Tracked so getAuthHeader can
// await it rather than observing a torn pre-mint state.
const mintPromise = useWorkspaceAuthStore().mintAtLogin()
pendingUnifiedMint = mintPromise
void mintPromise.finally(() => {
if (pendingUnifiedMint === mintPromise) pendingUnifiedMint = null
})
}

// Reset balance when auth state changes
Expand Down Expand Up @@ -235,8 +246,9 @@ export const useAuthStore = defineStore('auth', () => {
/**
* Retrieves the appropriate authentication header for API requests.
*
* When unified_cloud_auth is enabled, returns the single Cloud JWT for every
* cloud request (no Firebase/API-key fallback) so one token is used end to end.
* When unified_cloud_auth is enabled, awaits any in-flight login mint and
* returns the single Cloud JWT; if minting failed, falls back to the
* Firebase token rather than reporting an authenticated user as logged out.
* Otherwise checks for authentication in the following order:
* 1. Workspace token on Cloud when the user has active workspace context
* 2. Firebase authentication token (if user is logged in)
Expand All @@ -249,8 +261,14 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (flags.unifiedCloudAuthEnabled) {
if (pendingUnifiedMint) {
await pendingUnifiedMint.catch(() => false)
}
const token = useWorkspaceAuthStore().getUnifiedToken()
return token ? { Authorization: `Bearer ${token}` } : null
if (token) return { Authorization: `Bearer ${token}` }
// Mint failed to produce a token; fall back to Firebase rather than
// reporting a genuinely authenticated user as logged out.
return await getFirebaseAuthHeader()
}

if (isCloud) {
Expand Down
Loading