Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
166 changes: 164 additions & 2 deletions src/stores/__tests__/authTokenPriority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,36 @@ 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()
})

it('getAuthHeader falls back to the Firebase token when the unified mint rejects outright', async () => {
mockMintAtLogin.mockRejectedValueOnce(new Error('mint request failed'))
authStateCallback({ ...mockUser, uid: 'header-reject-user' })
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 +337,145 @@ describe('auth token priority chain', () => {
expect(token).toBeUndefined()
expect(mockUser.getIdToken).not.toHaveBeenCalled()
})

it('getAuthToken resolves instead of throwing when the unified mint rejects outright', async () => {
mockMintAtLogin.mockRejectedValueOnce(new Error('mint request failed'))
authStateCallback({ ...mockUser, uid: 'token-reject-user' })
mockUnifiedToken = null

await expect(store.getAuthToken()).resolves.toBeUndefined()
})

it('getAuthHeader awaits an in-flight unified mint instead of racing it', async () => {
let resolveMint: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveMint = resolve
})
)
authStateCallback({ ...mockUser, uid: 'header-race-user' })
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' })
})

it('getAuthToken awaits an in-flight unified mint instead of racing it', async () => {
let resolveMint: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveMint = resolve
})
)
authStateCallback({ ...mockUser, uid: 'token-race-user' })
mockUnifiedToken = null

let settled = false
const tokenPromise = store.getAuthToken().then((token) => {
settled = true
return token
})
await Promise.resolve()
expect(settled).toBe(false)

resolveMint(false)
const token = await tokenPromise

expect(settled).toBe(true)
expect(token).toBeUndefined()
})

it('shares a single in-flight mint across concurrent getAuthHeader callers', async () => {
mockMintAtLogin.mockClear()
let resolveMint: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveMint = resolve
})
)
authStateCallback({ ...mockUser, uid: 'concurrent-user' })
mockUnifiedToken = null
expect(mockMintAtLogin).toHaveBeenCalledTimes(1)

const header1Promise = store.getAuthHeader()
const header2Promise = store.getAuthHeader()
expect(mockMintAtLogin).toHaveBeenCalledTimes(1)

resolveMint(false)
const [header1, header2] = await Promise.all([
header1Promise,
header2Promise
])

expect(header1).toEqual({ Authorization: 'Bearer firebase-token' })
expect(header2).toEqual({ Authorization: 'Bearer firebase-token' })
})

it('does not let a stale mint from a previous identity clobber the new identity', async () => {
let resolveA: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveA = resolve
})
)
authStateCallback({ ...mockUser, uid: 'user-a' })

let resolveB: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveB = resolve
})
)
authStateCallback({
...mockUser,
uid: 'user-b',
email: 'b@example.com'
})
mockUnifiedToken = null

const headerPromise = store.getAuthHeader()

resolveA(true)
await Promise.resolve()

mockUnifiedToken = 'b-token'
resolveB(true)
const header = await headerPromise

expect(header).toEqual({ Authorization: 'Bearer b-token' })
expect(mockClearWorkspaceContext).toHaveBeenCalledTimes(2)
})

it('returns immediately on sign-out instead of waiting on an abandoned mint', async () => {
let resolveMint: (minted: boolean) => void = () => {}
mockMintAtLogin.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveMint = resolve
})
)
authStateCallback({ ...mockUser, uid: 'signing-out-user' })
mockUnifiedToken = null

authStateCallback(null)

const header = await store.getAuthHeader()
const token = await store.getAuthToken()

expect(header).toBeNull()
expect(token).toBeUndefined()

resolveMint(false)
})
})
})
24 changes: 18 additions & 6 deletions src/stores/authStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemoize } from '@vueuse/core'
import { FirebaseError } from 'firebase/app'
import {
AuthErrorCodes,
Expand Down Expand Up @@ -88,6 +89,9 @@ export const useAuthStore = defineStore('auth', () => {
let customerRecovery: Promise<void> | null = null
let customerRecoveryUid: string | undefined
const isFetchingBalance = ref(false)
const mintUnifiedToken = useMemoize((_uid: string) =>
useWorkspaceAuthStore().mintAtLogin()
)

// Balance state
const balance = ref<GetCustomerBalanceResponse | null>(null)
Expand Down Expand Up @@ -144,6 +148,7 @@ export const useAuthStore = defineStore('auth', () => {

if (user === null || identityChanged) {
useWorkspaceAuthStore().clearWorkspaceContext()
mintUnifiedToken.clear()
}
if (identityChanged) {
useTeamWorkspaceStore().resetForIdentityChange()
Expand All @@ -164,7 +169,7 @@ export const useAuthStore = defineStore('auth', () => {
} 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()
void mintUnifiedToken(user.uid)
}

// Reset balance when auth state changes
Expand Down Expand Up @@ -235,8 +240,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 +255,11 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (flags.unifiedCloudAuthEnabled) {
const uid = currentUser.value?.uid
if (uid) await mintUnifiedToken(uid).catch(() => false)
const token = useWorkspaceAuthStore().getUnifiedToken()
return token ? { Authorization: `Bearer ${token}` } : null
if (token) return { Authorization: `Bearer ${token}` }
return await getFirebaseAuthHeader()
}

if (isCloud) {
Expand Down Expand Up @@ -288,12 +297,15 @@ export const useAuthStore = defineStore('auth', () => {

/**
* Returns the raw auth token (not wrapped in a header object).
* When unified_cloud_auth is enabled, returns the single Cloud JWT; otherwise
* priority is workspace token > Firebase token.
* When unified_cloud_auth is enabled, awaits any in-flight login mint and
* returns the single Cloud JWT; otherwise priority is workspace token >
* Firebase token.
* Use this for WebSocket connections and backend node auth.
*/
const getAuthToken = async (): Promise<string | undefined> => {
if (flags.unifiedCloudAuthEnabled) {
const uid = currentUser.value?.uid
if (uid) await mintUnifiedToken(uid).catch(() => false)
return useWorkspaceAuthStore().getUnifiedToken()
}

Expand Down
Loading