fix(frontend): prevent invalid auth state

- Fix some frontend login race conditions related to vue state not flushing before redirects happen, resulting in stale identity data being loaded into local storage
- Extract local storage hooks out of the useIdentity hook so as to have a single shared ref for all of them, preventing accidental overwrites from race conditions when waiting for storage events to update other listeners
- Update public key confirmation logic to cater for a situation where an auth0 login was required, but keys were still saved before being confirmed.

Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
This commit is contained in:
Edward Sammut Alessi 2026-03-25 14:07:20 +01:00
parent 44562c97eb
commit 84149d69b0
No known key found for this signature in database
GPG Key ID: 65558E016966977A
11 changed files with 117 additions and 95 deletions

View File

@ -0,0 +1,16 @@
// Copyright (c) 2026 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { expect, test } from '../auth_fixtures'
test.describe.configure({ mode: 'parallel' })
test('Logout', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'user actions' }).click()
await page.getByRole('menuitem', { name: 'Log Out' }).click()
await expect(page.getByText('Log in')).toBeVisible()
})

View File

@ -52,7 +52,7 @@ const name = computed(() => fullname || auth0?.user?.value?.name)
<span class="truncate">{{ identity }}</span>
</div>
<div class="shrink-0">
<TActionsBox v-if="withLogoutControls">
<TActionsBox v-if="withLogoutControls" aria-label="user actions">
<TActionsBoxItem @select="logout">Log Out</TActionsBoxItem>
</TActionsBox>
</div>

View File

@ -6,6 +6,7 @@ import { useAuth0 } from '@auth0/auth0-vue'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ref } from 'vue'
import { RequestError } from '@/api/fetch.pb'
import { Code } from '@/api/google/rpc/code.pb'
import { AuthService } from '@/api/omni/auth/auth.pb'
import { AuthType, authType } from '@/methods'
@ -117,7 +118,7 @@ describe('useLogout', () => {
})
test('should not throw when RevokePublicKey fails with UNAUTHENTICATED error', async () => {
const error = new Error('Unauthenticated') as Error & { code: Code }
const error = new RequestError('Unauthenticated')
error.code = Code.UNAUTHENTICATED
vi.mocked(AuthService.RevokePublicKey).mockRejectedValue(error)

View File

@ -5,9 +5,10 @@
import { useAuth0 } from '@auth0/auth0-vue'
import type { ComputedRef, Ref } from 'vue'
import { computed, onBeforeMount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeMount, ref, watch } from 'vue'
import { Runtime } from '@/api/common/omni.pb'
import { RequestError } from '@/api/fetch.pb'
import { Code } from '@/api/google/rpc/code.pb'
import type { Resource } from '@/api/grpc'
import { ResourceService } from '@/api/grpc'
@ -256,24 +257,27 @@ export function useLogout() {
if (keys.publicKeyID.value) {
try {
await AuthService.RevokePublicKey({ public_key_id: keys.publicKeyID.value })
} catch (error) {
} catch (e) {
// During a log out action being unauthenticated is fine
if (error.code !== Code.UNAUTHENTICATED) throw error
if (!(e instanceof RequestError) || e.code !== Code.UNAUTHENTICATED) throw e
}
}
await auth0?.logout({
logoutParams: {
returnTo: window.location.origin,
},
})
keys.clear()
identity.clear()
currentUser.value = undefined
if (authType.value !== AuthType.Auth0) {
// Wait for storages to be set
await nextTick()
if (auth0) {
await auth0.logout({
logoutParams: {
returnTo: window.location.origin,
},
})
} else {
redirectToURL(`/logout?${AuthFlowQueryParam}=${FrontendAuthFlow}`)
}
}

View File

@ -11,9 +11,9 @@ describe('useIdentity', () => {
test('defaults to null', async () => {
const { identity, avatar, fullname } = useIdentity()
expect(identity.value).toBeNull()
expect(avatar.value).toBeNull()
expect(fullname.value).toBeNull()
expect(identity.value).toBe('')
expect(avatar.value).toBe('')
expect(fullname.value).toBe('')
})
test('sets values', async () => {
@ -37,9 +37,9 @@ describe('useIdentity', () => {
clear()
expect(identity.value).toBeNull()
expect(avatar.value).toBeNull()
expect(fullname.value).toBeNull()
expect(identity.value).toBe('')
expect(avatar.value).toBe('')
expect(fullname.value).toBe('')
})
test('persists values', async () => {

View File

@ -2,21 +2,26 @@
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { useLocalStorage } from '@vueuse/core'
import { StorageSerializers, useLocalStorage, type UseStorageOptions } from '@vueuse/core'
const storageOptions: UseStorageOptions<string> = {
serializer: StorageSerializers.string,
writeDefaults: false,
}
const identityRef = useLocalStorage('identity', '', storageOptions)
const fullnameRef = useLocalStorage('fullname', '', storageOptions)
const avatarRef = useLocalStorage('avatar', '', storageOptions)
export function useIdentity() {
const identityRef = useLocalStorage<string>('identity', null)
const fullnameRef = useLocalStorage<string>('fullname', null)
const avatarRef = useLocalStorage<string>('avatar', null)
return {
identity: identityRef,
fullname: fullnameRef,
avatar: avatarRef,
clear() {
identityRef.value = null
fullnameRef.value = null
avatarRef.value = null
identityRef.value = ''
fullnameRef.value = ''
avatarRef.value = ''
},
}
}

View File

@ -23,7 +23,7 @@ vi.mock(import('@/methods/key'), async (importOriginal) => {
useKeys: vi.fn(() => ({
keyPair: ref(mockKey),
publicKeyID: ref('public_key_id'),
keyExpirationTime: ref(null),
keyExpirationTime: ref(new Date(0)),
clear: vi.fn(),
})),
signDetached: vi.fn().mockResolvedValue(new ArrayBuffer(10)),
@ -193,7 +193,7 @@ describe('useRegisterAPIInterceptor', () => {
vi.mocked(useKeys).mockReturnValue({
keyPair: keyPairRef,
publicKeyID: ref('public_key_id'),
keyExpirationTime: ref(null),
keyExpirationTime: ref(new Date(0)),
clear: vi.fn(),
})

View File

@ -2,9 +2,10 @@
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { until } from '@vueuse/core'
import { getUnixTime } from 'date-fns'
import fetchIntercept from 'fetch-intercept'
import { onBeforeMount, onUnmounted, ref, watch } from 'vue'
import { onScopeDispose } from 'vue'
import { b64Encode } from '@/api/fetch.pb'
import {
@ -25,63 +26,50 @@ export function useRegisterAPIInterceptor() {
const { keyPair, publicKeyID } = useKeys()
const { identity } = useIdentity()
const unregisterInterceptor = ref<() => void>()
onBeforeMount(() => {
unregisterInterceptor.value = fetchIntercept.register({
async request(url, config?: { headers?: Headers; method?: string }) {
url = encodeURI(url)
if (
!/^\/(api|image)/.test(url) ||
(url.startsWith('/api/auth.') && !url.startsWith('/api/auth.AuthService/RevokePublicKey'))
) {
return [url, config]
}
config ||= {}
config.headers ||= new Headers()
const ts = getUnixTime(Date.now()).toString()
if (url.startsWith('/api')) {
config.headers.set(`Grpc-Metadata-${TimestampHeaderKey}`, ts)
const payload = JSON.stringify(buildPayload(url, config))
const signature = await generateSignatureHeader(payload)
config.headers.set(`Grpc-Metadata-${PayloadHeaderKey}`, payload)
config.headers.set(`Grpc-Metadata-${SignatureHeaderKey}`, signature)
} else if (url.startsWith('/image')) {
config.headers.set(TimestampHeaderKey, ts)
const sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' // empty string sha256
const payload = [config.method ?? 'GET', url, ts, sha256].join('\n')
const signature = await generateSignatureHeader(payload)
config.headers.set(SignatureHeaderKey, signature)
}
const unregister = fetchIntercept.register({
async request(url, config?: { headers?: Headers; method?: string }) {
url = encodeURI(url)
if (
!/^\/(api|image)/.test(url) ||
(url.startsWith('/api/auth.') && !url.startsWith('/api/auth.AuthService/RevokePublicKey'))
) {
return [url, config]
},
})
}
config ||= {}
config.headers ||= new Headers()
const ts = getUnixTime(Date.now()).toString()
if (url.startsWith('/api')) {
config.headers.set(`Grpc-Metadata-${TimestampHeaderKey}`, ts)
const payload = JSON.stringify(buildPayload(url, config))
const signature = await generateSignatureHeader(payload)
config.headers.set(`Grpc-Metadata-${PayloadHeaderKey}`, payload)
config.headers.set(`Grpc-Metadata-${SignatureHeaderKey}`, signature)
} else if (url.startsWith('/image')) {
config.headers.set(TimestampHeaderKey, ts)
const sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' // empty string sha256
const payload = [config.method ?? 'GET', url, ts, sha256].join('\n')
const signature = await generateSignatureHeader(payload)
config.headers.set(SignatureHeaderKey, signature)
}
return [url, config]
},
})
onScopeDispose(unregister)
async function generateSignatureHeader(payload: string) {
if (!keyPair.value) {
// Wait for keys to be created.
await new Promise<void>((resolve) => {
const handle = watch(
keyPair,
(keyPair) => {
if (!keyPair) return
handle.stop()
resolve()
},
{ immediate: true },
)
})
await until(keyPair).toBeTruthy()
}
const array = new Uint8Array(await signDetached(payload, keyPair.value!))
@ -90,8 +78,6 @@ export function useRegisterAPIInterceptor() {
return `${SignatureVersionV1} ${identity.value} ${fingerprint} ${signature}`
}
onUnmounted(() => unregisterInterceptor.value?.())
}
const includedHeaders = [

View File

@ -68,7 +68,7 @@ describe('useKeys', () => {
const { keyPair, keyExpirationTime, publicKeyID } = useKeys()
expect(keyPair.value).toBeFalsy()
expect(keyExpirationTime.value).toBeFalsy()
expect(keyExpirationTime.value).toEqual(new Date(0))
expect(publicKeyID.value).toBeFalsy()
})
@ -82,7 +82,7 @@ describe('useKeys', () => {
clear()
expect(keyPair.value).toBeFalsy()
expect(keyExpirationTime.value).toBeFalsy()
expect(keyExpirationTime.value).toEqual(new Date(0))
expect(publicKeyID.value).toBeFalsy()
})

View File

@ -2,7 +2,7 @@
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
import { useLocalStorage } from '@vueuse/core'
import { StorageSerializers, until, useLocalStorage } from '@vueuse/core'
import { useIDBKeyval } from '@vueuse/integrations/useIDBKeyval'
import { add, differenceInMilliseconds, formatRFC3339, isAfter } from 'date-fns'
import { watchEffect } from 'vue'
@ -15,9 +15,18 @@ import { AuthFlowQueryParam, FrontendAuthFlow, RedirectQueryParam } from '@/api/
const { data: keyPair, isFinished: keyPairLoaded } = useIDBKeyval<CryptoKeyPair | null>(
'keyPair',
null,
{ writeDefaults: false },
)
const keyExpirationTime = useLocalStorage<Date | null>('keyExpirationTime', null)
const publicKeyID = useLocalStorage<string | null>('publicKeyID', null)
const keyExpirationTime = useLocalStorage<Date>('keyExpirationTime', new Date(0), {
serializer: StorageSerializers.date,
writeDefaults: false,
})
const publicKeyID = useLocalStorage<string>('publicKeyID', '', {
serializer: StorageSerializers.string,
writeDefaults: false,
})
export function useKeys() {
return {
@ -26,8 +35,8 @@ export function useKeys() {
publicKeyID,
clear() {
keyPair.value = null
keyExpirationTime.value = null
publicKeyID.value = null
keyExpirationTime.value = new Date(0)
publicKeyID.value = ''
},
}
}
@ -96,7 +105,7 @@ export async function signDetached(data: string, keyPair: CryptoKeyPair) {
export async function hasValidKeys() {
// IndexedDB is async storage, and might not yet have been initialised
if (!keyPairLoaded.value) return new Promise((r) => setTimeout(() => r(hasValidKeys()), 20))
if (!keyPairLoaded.value) await until(keyPairLoaded).toBe(true)
if (!keyPair.value || !keyExpirationTime.value) return false

View File

@ -9,7 +9,7 @@ import type { User } from '@auth0/auth0-spa-js'
import type { Auth0VueClient } from '@auth0/auth0-vue'
import { useAuth0 } from '@auth0/auth0-vue'
import { jwtDecode } from 'jwt-decode'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { b64Encode, type fetchOption, RequestError } from '@/api/fetch.pb'
@ -153,9 +153,9 @@ const generatePublicKey = async (identity: string) => {
return
}
try {
await confirmPublicKey(res.publicKeyId, res.keyPair)
} catch {
await confirmPublicKey(res.publicKeyId, res.keyPair)
if (!confirmed.value) {
keysGenerating.value = false
return
}
@ -168,6 +168,9 @@ const generatePublicKey = async (identity: string) => {
identityStorage.fullname.value = name.value ?? ''
identityStorage.avatar.value = picture.value ?? ''
// Wait for storages to be set
await nextTick()
const redirect = route.query[RedirectQueryParam]?.toString()
if (!redirect) {
@ -225,8 +228,6 @@ const confirmPublicKey = async (publicKeyId: string, keyPair?: CryptoKeyPair) =>
}
showError('Failed to confirm public key', e.message)
throw e
}
}