fix(stage-ui): synchronize auth state across windows (#2256)

This commit is contained in:
Neko
2026-08-11 22:42:03 +08:00
committed by GitHub
parent d0b809a1f8
commit 2433e61378
19 changed files with 372 additions and 280 deletions
+1 -2
View File
@@ -2,7 +2,7 @@ import type { URLOpenListenerEvent } from '@capacitor/app'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { App } from '@capacitor/app' import { App } from '@capacitor/app'
import { applyOIDCTokens, fetchSession } from '@proj-airi/stage-ui/libs/auth' import { applyOIDCTokens } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc' import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
export function installDeepLinks(router: Router): void { export function installDeepLinks(router: Router): void {
@@ -25,7 +25,6 @@ export function installDeepLinks(router: Router): void {
} }
const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state) const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state)
await applyOIDCTokens(tokens, persisted.params.clientId) await applyOIDCTokens(tokens, persisted.params.clientId)
await fetchSession()
router.replace('/') router.replace('/')
} }
} }
+3 -6
View File
@@ -28,7 +28,6 @@ import { createGlobalAppConfig } from './configs/global'
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
import { setElectronMainDirname } from './libs/electron/location' import { setElectronMainDirname } from './libs/electron/location'
import { createI18n } from './libs/i18n' import { createI18n } from './libs/i18n'
import { createWindowAuthManagerService } from './services/airi/auth'
import { setupServerChannel } from './services/airi/channel-server' import { setupServerChannel } from './services/airi/channel-server'
import { setupGodotStageManager } from './services/airi/godot-stage' import { setupGodotStageManager } from './services/airi/godot-stage'
import { setupBuiltInServer } from './services/airi/http-server' import { setupBuiltInServer } from './services/airi/http-server'
@@ -180,8 +179,6 @@ app.whenReady().then(async () => {
build: ({ dependsOn }) => setupExtensionHost(dependsOn), build: ({ dependsOn }) => setupExtensionHost(dependsOn),
}) })
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService()) const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
// BeatSync will create a background window to capture and process audio. // BeatSync will create a background window to capture and process audio.
@@ -190,7 +187,7 @@ app.whenReady().then(async () => {
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow()) const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
const onboardingWindowManager = injeca.provide('windows:onboarding', { const onboardingWindowManager = injeca.provide('windows:onboarding', {
dependsOn: { serverChannel, i18n, windowAuthManager }, dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
}) })
@@ -220,7 +217,7 @@ app.whenReady().then(async () => {
}) })
const settingsWindow = injeca.provide('windows:settings', { const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut, spotlightWindow }, dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
build: async ({ dependsOn }) => build: async ({ dependsOn }) =>
setupSettingsWindowReusableFunc({ setupSettingsWindowReusableFunc({
...dependsOn, ...dependsOn,
@@ -229,7 +226,7 @@ app.whenReady().then(async () => {
}) })
const mainWindow = injeca.provide('windows:main', { const mainWindow = injeca.provide('windows:main', {
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager }, dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager },
build: async ({ dependsOn }) => setupMainWindow({ build: async ({ dependsOn }) => setupMainWindow({
...dependsOn, ...dependsOn,
onWindowCreated: (window) => { onWindowCreated: (window) => {
@@ -34,54 +34,13 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
let closeLoopback: (() => void) | null = null let closeLoopback: (() => void) | null = null
let signingInFlight = false let signingInFlight = false
export interface WindowAuthManager {
registerWindow: (params: { context: MainContext, window: BrowserWindow }) => void
broadcastAuthCallback: (tokens: TokenExchangeResult) => void
broadcastAuthError: (error: string) => void
}
export function createWindowAuthManagerService(): WindowAuthManager {
const authContexts = new Set<MainContext>()
function broadcastAuthCallback(tokens: TokenExchangeResult): void {
for (const context of authContexts) {
context.emit(electronAuthCallback, tokens)
}
}
function broadcastAuthError(error: string): void {
for (const context of authContexts) {
context.emit(electronAuthCallbackError, { error })
}
}
return {
registerWindow(params) {
authContexts.add(params.context)
params.window.on('closed', () => {
authContexts.delete(params.context)
})
},
broadcastAuthCallback,
broadcastAuthError,
}
}
/** /**
* Create the auth service IPC handlers for a given window context. * Create the auth service IPC handlers for a given window context.
*/ */
export function createAuthService(params: { export function createAuthService(params: {
context: MainContext context: MainContext
window: BrowserWindow window: BrowserWindow
windowAuthManager: WindowAuthManager
}): void { }): void {
params.windowAuthManager.registerWindow({
context: params.context,
window: params.window,
})
defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => { defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => {
if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) {
return return
@@ -136,12 +95,12 @@ export function createAuthService(params: {
loopback.result loopback.result
.then(async ({ code }) => { .then(async ({ code }) => {
const tokens = await exchangeCode(code, codeVerifier, redirectUri) const tokens = await exchangeCode(code, codeVerifier, redirectUri)
params.windowAuthManager.broadcastAuthCallback(tokens) params.context.emit(electronAuthCallback, tokens)
log.log('OIDC token exchange successful') log.log('OIDC token exchange successful')
}) })
.catch((err) => { .catch((err) => {
log.withError(err).error('OIDC signing in failed') log.withError(err).error('OIDC signing in failed')
params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed') params.context.emit(electronAuthCallbackError, { error: errorMessageFrom(err) ?? 'OIDC signing in failed' })
}) })
.finally(() => { .finally(() => {
closeLoopback = null closeLoopback = null
@@ -152,7 +111,7 @@ export function createAuthService(params: {
closeLoopback = null closeLoopback = null
signingInFlight = false signingInFlight = false
log.withError(err).error('Failed to start OIDC signing in flow') log.withError(err).error('Failed to start OIDC signing in flow')
params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed') params.context.emit(electronAuthCallbackError, { error: errorMessageFrom(err) ?? 'OIDC signing in failed' })
} }
}) })
@@ -2,7 +2,6 @@ import type { Rectangle } from 'electron'
import type { InferOutput } from 'valibot' import type { InferOutput } from 'valibot'
import type { I18n } from '../../libs/i18n' import type { I18n } from '../../libs/i18n'
import type { WindowAuthManager } from '../../services/airi/auth'
import type { ServerChannel } from '../../services/airi/channel-server' import type { ServerChannel } from '../../services/airi/channel-server'
import type { GodotStageManager } from '../../services/airi/godot-stage' import type { GodotStageManager } from '../../services/airi/godot-stage'
import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { McpStdioManager } from '../../services/airi/mcp-servers'
@@ -61,7 +60,6 @@ export async function setupMainWindow(params: {
mcpStdioManager: McpStdioManager mcpStdioManager: McpStdioManager
i18n: I18n i18n: I18n
onboardingWindowManager: OnboardingWindowManager onboardingWindowManager: OnboardingWindowManager
windowAuthManager: WindowAuthManager
}) { }) {
const { const {
setup: setupConfig, setup: setupConfig,
@@ -186,7 +184,6 @@ export async function setupMainWindow(params: {
mcpStdioManager: params.mcpStdioManager, mcpStdioManager: params.mcpStdioManager,
i18n: params.i18n, i18n: params.i18n,
onboardingWindowManager: params.onboardingWindowManager, onboardingWindowManager: params.onboardingWindowManager,
windowAuthManager: params.windowAuthManager,
}) })
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))) await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')))
@@ -1,7 +1,6 @@
import type { BrowserWindow } from 'electron' import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n' import type { I18n } from '../../../libs/i18n'
import type { WindowAuthManager } from '../../../services/airi/auth'
import type { ServerChannel } from '../../../services/airi/channel-server' import type { ServerChannel } from '../../../services/airi/channel-server'
import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { GodotStageManager } from '../../../services/airi/godot-stage'
import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { McpStdioManager } from '../../../services/airi/mcp-servers'
@@ -40,7 +39,6 @@ export async function setupMainWindowElectronInvokes(params: {
mcpStdioManager: McpStdioManager mcpStdioManager: McpStdioManager
i18n: I18n i18n: I18n
onboardingWindowManager: OnboardingWindowManager onboardingWindowManager: OnboardingWindowManager
windowAuthManager: WindowAuthManager
}) { }) {
// TODO: once we refactored eventa to support window-namespaced contexts, // TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and // we can remove the setMaxListeners call below since eventa will be able to dispatch and
@@ -55,7 +53,7 @@ export async function setupMainWindowElectronInvokes(params: {
createMcpServersService({ context, manager: params.mcpStdioManager }) createMcpServersService({ context, manager: params.mcpStdioManager })
createGodotStageService({ context, manager: params.godotStageManager, window: params.window }) createGodotStageService({ context, manager: params.godotStageManager, window: params.window })
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager }) createAuthService({ context, window: params.window })
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window)) defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window))
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
@@ -1,5 +1,4 @@
import type { I18n } from '../../libs/i18n' import type { I18n } from '../../libs/i18n'
import type { WindowAuthManager } from '../../services/airi/auth'
import type { ServerChannel } from '../../services/airi/channel-server' import type { ServerChannel } from '../../services/airi/channel-server'
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path'
@@ -28,7 +27,6 @@ export interface OnboardingWindowManager {
export function setupOnboardingWindowManager(params: { export function setupOnboardingWindowManager(params: {
serverChannel: ServerChannel serverChannel: ServerChannel
i18n: I18n i18n: I18n
windowAuthManager: WindowAuthManager
}): OnboardingWindowManager { }): OnboardingWindowManager {
const closeCallbacks = new Set<() => void>() const closeCallbacks = new Set<() => void>()
@@ -74,7 +72,7 @@ export function setupOnboardingWindowManager(params: {
}) })
await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel })
createAuthService({ context, window: newWindow, windowAuthManager: params.windowAuthManager }) createAuthService({ context, window: newWindow })
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding')) await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))
@@ -1,5 +1,4 @@
import type { I18n } from '../../libs/i18n' import type { I18n } from '../../libs/i18n'
import type { WindowAuthManager } from '../../services/airi/auth'
import type { ServerChannel } from '../../services/airi/channel-server' import type { ServerChannel } from '../../services/airi/channel-server'
import type { GodotStageManager } from '../../services/airi/godot-stage' import type { GodotStageManager } from '../../services/airi/godot-stage'
import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { McpStdioManager } from '../../services/airi/mcp-servers'
@@ -37,7 +36,6 @@ export function setupSettingsWindowReusableFunc(params: {
godotStageManager: GodotStageManager godotStageManager: GodotStageManager
mcpStdioManager: McpStdioManager mcpStdioManager: McpStdioManager
i18n: I18n i18n: I18n
windowAuthManager: WindowAuthManager
globalShortcut: GlobalShortcutService globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager spotlightWindow: SpotlightWindowManager
}): SettingsWindowManager { }): SettingsWindowManager {
@@ -76,7 +74,6 @@ export function setupSettingsWindowReusableFunc(params: {
godotStageManager: params.godotStageManager, godotStageManager: params.godotStageManager,
mcpStdioManager: params.mcpStdioManager, mcpStdioManager: params.mcpStdioManager,
i18n: params.i18n, i18n: params.i18n,
windowAuthManager: params.windowAuthManager,
globalShortcut: params.globalShortcut, globalShortcut: params.globalShortcut,
spotlightWindow: params.spotlightWindow, spotlightWindow: params.spotlightWindow,
}) })
@@ -1,7 +1,6 @@
import type { BrowserWindow } from 'electron' import type { BrowserWindow } from 'electron'
import type { I18n } from '../../../libs/i18n' import type { I18n } from '../../../libs/i18n'
import type { WindowAuthManager } from '../../../services/airi/auth'
import type { ServerChannel } from '../../../services/airi/channel-server' import type { ServerChannel } from '../../../services/airi/channel-server'
import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { GodotStageManager } from '../../../services/airi/godot-stage'
import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { McpStdioManager } from '../../../services/airi/mcp-servers'
@@ -40,7 +39,6 @@ export async function setupSettingsWindowInvokes(params: {
godotStageManager: GodotStageManager godotStageManager: GodotStageManager
mcpStdioManager: McpStdioManager mcpStdioManager: McpStdioManager
i18n: I18n i18n: I18n
windowAuthManager: WindowAuthManager
globalShortcut: GlobalShortcutService globalShortcut: GlobalShortcutService
spotlightWindow: SpotlightWindowManager spotlightWindow: SpotlightWindowManager
}) { }) {
@@ -57,7 +55,7 @@ export async function setupSettingsWindowInvokes(params: {
createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager }) createMcpServersService({ context, manager: params.mcpStdioManager })
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow }) createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager }) createAuthService({ context, window: params.settingsWindow })
// Register the global shortcut service for the settings window. // Register the global shortcut service for the settings window.
params.globalShortcut.registerWindow({ context, window: params.settingsWindow }) params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
electronAuthCallback,
electronAuthCallbackError,
} from '../../shared/eventa'
import { initializeElectronAuthCallbackBridge } from './electron-auth-callback'
const authMocks = vi.hoisted(() => ({
completeSignIn: vi.fn(),
}))
const eventHandlers = vi.hoisted(() => new Map<object, (event: { body?: unknown }) => Promise<void> | void>())
vi.mock('@proj-airi/electron-vueuse', () => ({
getElectronEventaContext: () => ({
on: (event: object, handler: (event: { body?: unknown }) => Promise<void> | void) => {
eventHandlers.set(event, handler)
},
}),
}))
vi.mock('@proj-airi/stage-ui/stores/auth', () => ({
useAuthStore: () => ({
completeSignIn: authMocks.completeSignIn,
}),
}))
vi.mock('vue-sonner', () => ({
toast: {
error: vi.fn(),
},
}))
describe('electron auth callback bridge', () => {
beforeEach(() => {
eventHandlers.clear()
authMocks.completeSignIn.mockReset()
authMocks.completeSignIn.mockResolvedValue(true)
})
it('routes exchanged OIDC tokens through the auth store action', async () => {
// ROOT CAUSE:
//
// The callback wrote VueUse storage refs and queried the session at once.
// VueUse persisted the access token in the next microtask, so the session
// request could read the previous token and clear the complete auth state.
initializeElectronAuthCallbackBridge()
const handler = eventHandlers.get(electronAuthCallback)
expect(handler).toBeTypeOf('function')
await handler?.({
body: {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
idToken: 'new-id-token',
expiresIn: 3600,
},
})
expect(authMocks.completeSignIn).toHaveBeenCalledWith({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
idToken: 'new-id-token',
expiresIn: 3600,
clientId: 'airi-stage-electron',
})
expect(eventHandlers.has(electronAuthCallbackError)).toBe(true)
})
})
@@ -1,6 +1,5 @@
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
import { getElectronEventaContext } from '@proj-airi/electron-vueuse' import { getElectronEventaContext } from '@proj-airi/electron-vueuse'
import { fetchSession } from '@proj-airi/stage-ui/libs/auth'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -23,18 +22,10 @@ export function initializeElectronAuthCallbackBridge() {
return return
try { try {
const authStore = useAuthStore() await useAuthStore().completeSignIn({
authStore.token = tokens.accessToken ...tokens,
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron',
if (tokens.refreshToken) { })
authStore.refreshToken = tokens.refreshToken
}
authStore.oidcClientId = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron'
authStore.tokenExpiry = Date.now() + tokens.expiresIn * 1000
authStore.scheduleTokenRefresh(tokens.expiresIn)
await fetchSession()
} }
catch (error) { catch (error) {
toast.error(errorMessageFrom(error) ?? 'Sign-in failed') toast.error(errorMessageFrom(error) ?? 'Sign-in failed')
+1 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { errorMessageFrom } from '@moeru/std' import { errorMessageFrom } from '@moeru/std'
import { useAnalytics } from '@proj-airi/stage-ui/composables' import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { applyOIDCTokens, fetchSession, triggerSignIn } from '@proj-airi/stage-ui/libs/auth' import { applyOIDCTokens, triggerSignIn } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc' import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
import { Button } from '@proj-airi/ui' import { Button } from '@proj-airi/ui'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
@@ -41,7 +41,6 @@ onMounted(async () => {
try { try {
const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state) const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state)
await applyOIDCTokens(tokens, persisted.params.clientId) await applyOIDCTokens(tokens, persisted.params.clientId)
await fetchSession()
router.replace('/') router.replace('/')
} }
catch (err) { catch (err) {
@@ -18,6 +18,8 @@ const syncState = vi.hoisted(() => ({
})) }))
const syncMocks = vi.hoisted(() => ({ const syncMocks = vi.hoisted(() => ({
initializeAuth: vi.fn(async () => {}),
leadershipHook: undefined as ((isLeader: boolean) => void) | undefined,
forceProviderConfigured: vi.fn(), forceProviderConfigured: vi.fn(),
setProviderUnconfigured: vi.fn(), setProviderUnconfigured: vi.fn(),
setProviderAvailabilityOverride: vi.fn(), setProviderAvailabilityOverride: vi.fn(),
@@ -28,7 +30,16 @@ const syncMocks = vi.hoisted(() => ({
})) }))
vi.mock('../libs/auth', () => ({ vi.mock('../libs/auth', () => ({
initializeAuth: vi.fn(), initializeAuth: syncMocks.initializeAuth,
}))
vi.mock('../libs/pinia', () => ({
usePiniaSynced: () => ({
onLeadershipChange: (hook: (isLeader: boolean) => void) => {
syncMocks.leadershipHook = hook
return vi.fn()
},
}),
})) }))
vi.mock('../libs/providers', () => ({ vi.mock('../libs/providers', () => ({
@@ -109,6 +120,7 @@ describe('useAuthProviderSync', () => {
beforeEach(() => { beforeEach(() => {
syncState.authenticatedHook = undefined syncState.authenticatedHook = undefined
syncState.logoutHook = undefined syncState.logoutHook = undefined
syncMocks.leadershipHook = undefined
syncState.activeProvider = '' syncState.activeProvider = ''
syncState.activeModel = '' syncState.activeModel = ''
syncState.activeVisionProvider = '' syncState.activeVisionProvider = ''
@@ -122,6 +134,16 @@ describe('useAuthProviderSync', () => {
syncMocks.fetchModelsForProvider.mockResolvedValue([]) syncMocks.fetchModelsForProvider.mockResolvedValue([])
}) })
it('restores auth initialization when this renderer becomes the leader', async () => {
useAuthProviderSync()
expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(1)
syncMocks.leadershipHook?.(true)
await Promise.resolve()
expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(2)
})
it('activates every official provider after direct sign-in when no custom provider is selected', async () => { it('activates every official provider after direct sign-in when no custom provider is selected', async () => {
useAuthProviderSync() useAuthProviderSync()
@@ -1,6 +1,7 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { initializeAuth } from '../libs/auth' import { initializeAuth } from '../libs/auth'
import { usePiniaSynced } from '../libs/pinia'
import { getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers' import { getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useConsciousnessStore } from '../stores/modules/consciousness' import { useConsciousnessStore } from '../stores/modules/consciousness'
@@ -37,7 +38,14 @@ const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming'
* auxiliary windows do not depend on the transient Stage scene lifecycle. * auxiliary windows do not depend on the transient Stage scene lifecycle.
*/ */
export function useAuthProviderSync() { export function useAuthProviderSync() {
initializeAuth() void initializeAuth()
// A replacement leader has no active refresh timer. Restore the auth
// lifecycle when this renderer acquires leadership after another closes.
usePiniaSynced().onLeadershipChange((isLeader) => {
if (isLeader)
void initializeAuth()
})
const authStore = useAuthStore() const authStore = useAuthStore()
const providersStore = useProviderStore() const providersStore = useProviderStore()
+42
View File
@@ -0,0 +1,42 @@
import { createAuthClient } from 'better-auth/vue'
import { SERVER_URL } from './server'
import { steamClient } from './steam-auth-client'
function getPersistedAuthToken(): string | null {
return localStorage.getItem('auth/v1/token')
}
export const authClient = createAuthClient({
baseURL: SERVER_URL,
plugins: [steamClient()],
fetchOptions: {
// NOTICE: better-auth sets `credentials: "include"` by default.
// AIRI uses Bearer authentication and must not attach a browser session cookie.
credentials: 'omit',
auth: {
type: 'Bearer',
token: () => getPersistedAuthToken() ?? '',
},
},
})
/**
* Gets the session with the specified access token.
*
* The explicit token keeps the request independent from asynchronous storage writes.
*/
export async function requestAuthSession(accessToken: string | null) {
if (!accessToken)
return null
const { data } = await authClient.getSession({
fetchOptions: {
auth: {
type: 'Bearer',
token: accessToken,
},
},
})
return data
}
@@ -1,11 +1,9 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '../stores/auth'
import { authedFetch } from './auth-fetch' import { authedFetch } from './auth-fetch'
const authMocks = vi.hoisted(() => ({
getAuthToken: vi.fn(() => 'access-token'),
}))
const posthogMocks = vi.hoisted(() => ({ const posthogMocks = vi.hoisted(() => ({
getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({ getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
distinctId: 'distinct-1', distinctId: 'distinct-1',
@@ -13,10 +11,6 @@ const posthogMocks = vi.hoisted(() => ({
})), })),
})) }))
vi.mock('./auth', () => ({
getAuthToken: authMocks.getAuthToken,
}))
vi.mock('./analytics', () => ({ vi.mock('./analytics', () => ({
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot, getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot,
})) }))
@@ -24,7 +18,8 @@ vi.mock('./analytics', () => ({
describe('authedFetch', () => { describe('authedFetch', () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
authMocks.getAuthToken.mockReturnValue('access-token') setActivePinia(createPinia())
useAuthStore().token = 'access-token'
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({ posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
distinctId: 'distinct-1', distinctId: 'distinct-1',
sessionId: 'session-1', sessionId: 'session-1',
+6 -7
View File
@@ -1,6 +1,5 @@
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { getAnalyticsIdentitySnapshot } from './analytics' import { getAnalyticsIdentitySnapshot } from './analytics'
import { getAuthToken } from './auth'
import { SERVER_URL } from './server' import { SERVER_URL } from './server'
/** /**
@@ -23,6 +22,7 @@ export async function authedFetch(
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
): Promise<Response> { ): Promise<Response> {
const authStore = useAuthStore()
const doFetch = (token: string | null): Promise<Response> => { const doFetch = (token: string | null): Promise<Response> => {
const headers = new Headers(init?.headers) const headers = new Headers(init?.headers)
if (token) if (token)
@@ -36,7 +36,7 @@ export async function authedFetch(
return fetch(input, { ...init, headers, credentials: 'omit' }) return fetch(input, { ...init, headers, credentials: 'omit' })
} }
const response = await doFetch(getAuthToken()) const response = await doFetch(authStore.token)
if (response.status !== 401) if (response.status !== 401)
return response return response
@@ -47,16 +47,15 @@ export async function authedFetch(
if (url.includes('/oauth2/token')) if (url.includes('/oauth2/token'))
return response return response
const authStore = useAuthStore()
const newToken = await authStore.refreshTokenNow() const newToken = await authStore.refreshTokenNow()
if (!newToken) { if (!newToken) {
promptReLogin(authStore) await promptReLogin(authStore)
return response return response
} }
const retried = await doFetch(newToken) const retried = await doFetch(newToken)
if (retried.status === 401) if (retried.status === 401)
promptReLogin(authStore) await promptReLogin(authStore)
return retried return retried
} }
@@ -68,7 +67,7 @@ function shouldAttachPosthogIdentity(input: RequestInfo | URL): boolean {
return new URL(url, SERVER_URL).origin === new URL(SERVER_URL).origin return new URL(url, SERVER_URL).origin === new URL(SERVER_URL).origin
} }
function promptReLogin(authStore: ReturnType<typeof useAuthStore>): void { async function promptReLogin(authStore: ReturnType<typeof useAuthStore>): Promise<void> {
authStore.clearAllAuthState() await authStore.clearAllAuthState()
authStore.needsLogin = true authStore.needsLogin = true
} }
+15 -152
View File
@@ -1,183 +1,46 @@
import type { OIDCFlowParams, TokenResponse } from './auth-oidc' import type { OIDCFlowParams, TokenResponse } from './auth-oidc'
import { createAuthClient } from 'better-auth/vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { authClient } from './auth-client'
import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config' import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from './auth-config'
import { buildAuthorizationURL, persistFlowState } from './auth-oidc' import { buildAuthorizationURL, persistFlowState } from './auth-oidc'
import { SERVER_URL } from './server'
import { steamClient } from './steam-auth-client'
export type OAuthProvider = 'google' | 'github' | 'steam' export type OAuthProvider = 'google' | 'github' | 'steam'
// NOTICE: reads the same localStorage key ('auth/v1/token') that useAuthStore's /** Returns the access token from the active auth store. */
// `token` ref writes via useLocalStorage. We bypass the store here because
// authClient is initialized at module scope, before Pinia is active — calling
// useAuthStore() at this point would throw. The two stay in sync because
// useLocalStorage and raw localStorage share the same underlying storage entry.
export function getAuthToken(): string | null { export function getAuthToken(): string | null {
return localStorage.getItem('auth/v1/token') return useAuthStore().token
} }
export const authClient = createAuthClient({ export { authClient }
baseURL: SERVER_URL,
plugins: [steamClient()],
fetchOptions: {
// NOTICE: better-auth's client hardcodes `credentials: "include"` by default
// (config.mjs L40), which causes cookies to be sent alongside the Authorization
// header. We override with "omit" so only the Bearer token is used for auth.
// This works because restOfFetchOptions is spread AFTER the default (L47).
credentials: 'omit',
auth: {
type: 'Bearer',
token: () => getAuthToken() ?? '',
},
},
})
let initialized = false
export async function initializeAuth() { export async function initializeAuth() {
if (initialized) await useAuthStore().initialize()
return
// NOTICE: OIDC callback is handled by the dedicated callback page
// (e.g. /auth/callback). initializeAuth() only restores existing
// sessions and refresh schedules — it does NOT consume the code.
initialized = true
const authStore = useAuthStore()
// Normalize "half-cleared" persisted state before anything reads it.
//
// Why: `refreshToken` was added to the auth store before `oidcClientId`
// (commit c73ceeb1f predates f1fe161bc), and `clearOIDCState` (now removed)
// used to clear only the OIDC pair. Browsers that saw either code path can
// end up with a refreshToken but no oidcClientId, which makes
// `refreshTokenNow()` early-return forever — 401s then silently accumulate
// on non-home pages until the user lands on a route that calls fetchSession.
//
// Treat any mismatch as an unauthenticated session; the user will get a
// fresh OIDC login prompt via the standard 401→needsLogin path.
const hasRefreshToken = !!authStore.refreshToken
const hasClientId = !!authStore.oidcClientId
if (hasRefreshToken !== hasClientId)
authStore.clearAllAuthState()
// NOTICE: restoreRefreshSchedule must complete BEFORE fetchSession when
// the persisted access token is already expired. Otherwise fetchSession
// hits /get-session with the stale Bearer, gets 401, and wipes
// refreshToken + oidcClientId before the scheduled refresh can run —
// silently logging the user out on reload.
authStore.onTokenRefreshed(async (accessToken) => {
authStore.token = accessToken
await fetchSession()
})
await authStore.restoreRefreshSchedule()
await fetchSession().catch(() => {})
} }
/** /**
* Persist OIDC tokens locally and schedule refresh. * Persist OIDC tokens locally and schedule refresh.
*/ */
export async function applyOIDCTokens(tokens: TokenResponse, clientId: string): Promise<void> { export async function applyOIDCTokens(tokens: TokenResponse, clientId: string): Promise<void> {
const authStore = useAuthStore() await useAuthStore().completeSignIn({
authStore.token = tokens.access_token accessToken: tokens.access_token,
if (tokens.refresh_token) refreshToken: tokens.refresh_token,
authStore.refreshToken = tokens.refresh_token idToken: tokens.id_token,
// Persist the ID token so signOut() can drive RP-Initiated Logout via expiresIn: tokens.expires_in,
// `id_token_hint`. Token rotation does not refresh the ID token, so the clientId,
// value captured here at sign-in time is the one we use for the lifetime })
// of the local session.
if (tokens.id_token)
authStore.idToken = tokens.id_token
// Persist client info for refresh after page reload
authStore.oidcClientId = clientId
if (tokens.expires_in)
authStore.tokenExpiry = Date.now() + tokens.expires_in * 1000
authStore.scheduleTokenRefresh(tokens.expires_in)
} }
export async function fetchSession() { export async function fetchSession() {
const { data } = await authClient.getSession() return await useAuthStore().fetchSession()
const authStore = useAuthStore()
if (data) {
authStore.user = data.user
authStore.session = data.session
return true
}
// Session expired or invalid — clear stale auth state from localStorage
authStore.clearAllAuthState()
return false
} }
export async function listSessions() { export async function listSessions() {
return await authClient.listSessions() return await useAuthStore().listSessions()
} }
export async function signOut() { export async function signOut() {
const authStore = useAuthStore() await useAuthStore().signOut()
// Capture the bits we need before clearOIDCState() wipes them.
const idTokenHint = authStore.idToken
const clientId = authStore.oidcClientId
const bearerToken = authStore.token
// NOTICE:
// Authoritative server-side sign-out FIRST, then local clear. Do NOT make
// this optimistic.
//
// Why: the better-auth session cookie is SameSite=Lax. A top-level
// navigation to `/oauth2/authorize` (i.e. clicking "sign in" right after
// logout) will attach that cookie. If we clear local state first and let
// the user trigger a fresh OIDC flow before /end-session has actually
// deleted the session row, the server resolves the still-live row and
// silently re-issues tokens for the just-logged-out account. The user
// ends up logged back in as the previous identity.
//
// We pay the round-trip latency on the logout click in exchange for
// killing that race. Callers must display a loading indicator while
// awaiting (profile.vue gates the button via `signOutLoading`).
//
// OIDC RP-Initiated Logout (`/api/auth/oauth2/end-session`) is the
// Bearer-friendly path: it accepts `id_token_hint`, decodes the `sid`
// claim, and deletes the corresponding `session` row via
// `internalAdapter.deleteSession(session.token)`. Source:
// node_modules/@better-auth/oauth-provider/dist/index.mjs L996+. Requires
// the trusted OIDC client to be seeded with `enableEndSession: true`.
//
// Fallback to /api/auth/sign-out for sessions that pre-date id_token
// persistence (applyOIDCTokens started saving id_token in this branch);
// without it, those legacy sessions would skip server cleanup and hit
// exactly the silent-re-login bug described above.
try {
if (idTokenHint && clientId) {
const url = new URL('/api/auth/oauth2/end-session', SERVER_URL)
url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set('client_id', clientId)
await fetch(url.toString(), { method: 'GET' })
}
else if (bearerToken) {
const url = new URL('/api/auth/sign-out', SERVER_URL)
await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${bearerToken}` },
})
}
}
catch {
// Network failure: still clear local state below. Server-side row will
// expire by TTL; the local refreshToken/idToken/clientId are about to
// be wiped, so the local user has no way to spend it in the meantime.
}
authStore.clearAllAuthState()
} }
/** /**
+60
View File
@@ -1,19 +1,53 @@
import type { Session, User } from 'better-auth'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { triggerSignIn } from '../libs/auth' import { triggerSignIn } from '../libs/auth'
import { requestAuthSession } from '../libs/auth-client'
import { useAuthStore } from './auth' import { useAuthStore } from './auth'
vi.mock('../libs/auth', () => ({ vi.mock('../libs/auth', () => ({
triggerSignIn: vi.fn(), triggerSignIn: vi.fn(),
})) }))
vi.mock('../libs/auth-client', () => ({
authClient: {
listSessions: vi.fn(),
},
requestAuthSession: vi.fn(),
}))
vi.mock('../libs/auth-oidc', () => ({
refreshAccessToken: vi.fn(),
}))
const user: User = {
id: 'user-1',
name: 'AIRI User',
email: 'user@example.com',
emailVerified: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
}
const session: Session = {
id: 'session-1',
token: 'server-session-token',
userId: user.id,
expiresAt: new Date('2026-12-01T00:00:00.000Z'),
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
}
describe('auth store sign-in requests', () => { describe('auth store sign-in requests', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.mocked(triggerSignIn).mockReset() vi.mocked(triggerSignIn).mockReset()
vi.mocked(triggerSignIn).mockResolvedValue() vi.mocked(triggerSignIn).mockResolvedValue()
vi.mocked(requestAuthSession).mockReset()
vi.mocked(requestAuthSession).mockResolvedValue({ user, session })
}) })
it('allows sign-in to be requested again after an external flow is canceled', async () => { it('allows sign-in to be requested again after an external flow is canceled', async () => {
@@ -37,4 +71,30 @@ describe('auth store sign-in requests', () => {
expect(triggerSignIn).toHaveBeenCalledTimes(2) expect(triggerSignIn).toHaveBeenCalledTimes(2)
expect(authStore.needsLogin).toBe(false) expect(authStore.needsLogin).toBe(false)
}) })
it('queries the session with the token from the completed sign-in', async () => {
const authStore = useAuthStore()
// ROOT CAUSE:
//
// The old callback queried the session through a raw localStorage read.
// VueUse writes storage on the next microtask, so that request could use
// the previous token and then clear the complete auth state.
await authStore.completeSignIn({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
idToken: 'new-id-token',
expiresIn: 3600,
clientId: 'airi-stage-electron',
})
expect(requestAuthSession).toHaveBeenCalledWith('new-access-token')
expect(authStore.token).toBe('new-access-token')
expect(authStore.refreshToken).toBe('new-refresh-token')
expect(authStore.idToken).toBe('new-id-token')
expect(authStore.user).toEqual(user)
expect(authStore.session).toEqual(session)
await authStore.clearAllAuthState()
})
}) })
+127 -28
View File
@@ -1,5 +1,7 @@
import type { Session, User } from 'better-auth' import type { Session, User } from 'better-auth'
import type {} from 'pinia-plugin-synced'
import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared' import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { StorageSerializers, useLocalStorage, useTimeoutFn, whenever } from '@vueuse/core' import { StorageSerializers, useLocalStorage, useTimeoutFn, whenever } from '@vueuse/core'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -8,7 +10,18 @@ import { computed, ref, watch } from 'vue'
import { client } from '../composables/api' import { client } from '../composables/api'
import { useBreakpoints } from '../composables/use-breakpoints' import { useBreakpoints } from '../composables/use-breakpoints'
import { triggerSignIn } from '../libs/auth' import { triggerSignIn } from '../libs/auth'
import { authClient, requestAuthSession } from '../libs/auth-client'
import { refreshAccessToken } from '../libs/auth-oidc' import { refreshAccessToken } from '../libs/auth-oidc'
import { SERVER_URL } from '../libs/server'
/** Tokens that complete one OIDC sign-in flow. */
export interface AuthTokenSet {
accessToken: string
refreshToken?: string
idToken?: string
expiresIn: number
clientId: string
}
/** /**
* Auth store holds identity state and credits. * Auth store holds identity state and credits.
@@ -37,6 +50,7 @@ export const useAuthStore = defineStore('auth', () => {
// Persisted so refresh scheduling survives page reloads. // Persisted so refresh scheduling survives page reloads.
const oidcClientId = useLocalStorage<string | null>('auth/v1/oidc-client-id', null) const oidcClientId = useLocalStorage<string | null>('auth/v1/oidc-client-id', null)
const tokenExpiry = useLocalStorage<number | null>('auth/v1/oidc-token-expiry', null) const tokenExpiry = useLocalStorage<number | null>('auth/v1/oidc-token-expiry', null)
const initialized = ref(false)
const credits = useLocalStorage<number>('user/v1/flux', 0) const credits = useLocalStorage<number>('user/v1/flux', 0)
@@ -119,9 +133,6 @@ export const useAuthStore = defineStore('auth', () => {
// The delay ref is updated by scheduleTokenRefresh before calling start(). // The delay ref is updated by scheduleTokenRefresh before calling start().
const refreshDelayMs = ref(0) const refreshDelayMs = ref(0)
type TokenRefreshedHook = (accessToken: string) => void | Promise<void>
const tokenRefreshedHooks: TokenRefreshedHook[] = []
// Single-flight refresh: multiple concurrent callers (timer + 401 retry + restore) // Single-flight refresh: multiple concurrent callers (timer + 401 retry + restore)
// must not trigger multiple token exchanges. All share one in-flight promise. // must not trigger multiple token exchanges. All share one in-flight promise.
let inflightRefresh: Promise<string | null> | null = null let inflightRefresh: Promise<string | null> | null = null
@@ -144,19 +155,12 @@ export const useAuthStore = defineStore('auth', () => {
scheduleTokenRefresh(tokens.expires_in) scheduleTokenRefresh(tokens.expires_in)
} }
for (const hook of tokenRefreshedHooks) { await fetchSession(tokens.access_token)
try {
await hook(tokens.access_token)
}
catch (e) {
console.error('token refresh hook error', e)
}
}
return tokens.access_token return tokens.access_token
} }
catch { catch (error) {
clearAllAuthState() console.error('OIDC token refresh failed', errorMessageFrom(error))
clearAuthState()
return null return null
} }
finally { finally {
@@ -168,7 +172,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn( const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn(
() => { refreshTokenNow() }, () => { void useAuthStore().refreshTokenNow() },
refreshDelayMs, refreshDelayMs,
{ immediate: false }, { immediate: false },
) )
@@ -191,29 +195,105 @@ export const useAuthStore = defineStore('auth', () => {
* (when the persisted token is already expired) so callers can avoid * (when the persisted token is already expired) so callers can avoid
* racing `fetchSession()` against a stale Bearer token. * racing `fetchSession()` against a stale Bearer token.
*/ */
async function restoreRefreshSchedule(): Promise<void> { async function restoreRefreshSchedule(): Promise<boolean> {
if (!refreshToken.value || !oidcClientId.value) if (!refreshToken.value || !oidcClientId.value)
return return false
if (tokenExpiry.value) { if (tokenExpiry.value) {
const remainingMs = tokenExpiry.value - Date.now() const remainingMs = tokenExpiry.value - Date.now()
if (remainingMs > 0) { if (remainingMs > 0) {
scheduleTokenRefresh(remainingMs / 1000) scheduleTokenRefresh(remainingMs / 1000)
return return false
} }
} }
// Already expired — refresh synchronously so subsequent requests use fresh token // Already expired — refresh synchronously so subsequent requests use fresh token
await refreshTokenNow() return !!(await refreshTokenNow())
} }
function onTokenRefreshed(hook: TokenRefreshedHook) { async function initialize(): Promise<void> {
tokenRefreshedHooks.push(hook) if (initialized.value)
return () => { return
const idx = tokenRefreshedHooks.indexOf(hook)
if (idx >= 0) initialized.value = true
tokenRefreshedHooks.splice(idx, 1)
const hasRefreshToken = !!refreshToken.value
const hasClientId = !!oidcClientId.value
if (hasRefreshToken !== hasClientId) {
clearAuthState()
return
} }
const refreshed = await restoreRefreshSchedule()
if (!refreshed && token.value)
await fetchSession(token.value)
}
async function completeSignIn(tokens: AuthTokenSet): Promise<boolean> {
token.value = tokens.accessToken
refreshToken.value = tokens.refreshToken ?? null
idToken.value = tokens.idToken ?? null
oidcClientId.value = tokens.clientId
tokenExpiry.value = Number.isFinite(tokens.expiresIn)
? Date.now() + tokens.expiresIn * 1000
: null
scheduleTokenRefresh(tokens.expiresIn)
return await fetchSession(tokens.accessToken)
}
async function fetchSession(accessToken: string | null = token.value): Promise<boolean> {
const data = await requestAuthSession(accessToken)
if (data) {
user.value = data.user
session.value = data.session
return true
}
clearAuthState()
return false
}
async function listSessions() {
return await authClient.listSessions({
fetchOptions: {
auth: {
type: 'Bearer',
token: token.value ?? '',
},
},
})
}
async function signOut(): Promise<void> {
const idTokenHint = idToken.value
const clientId = oidcClientId.value
const bearerToken = token.value
// Delete the server session before local state. A new authorization request
// can otherwise reuse the server cookie and restore the previous identity.
try {
if (idTokenHint && clientId) {
const url = new URL('/api/auth/oauth2/end-session', SERVER_URL)
url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set('client_id', clientId)
await fetch(url.toString(), { method: 'GET' })
}
else if (bearerToken) {
const url = new URL('/api/auth/sign-out', SERVER_URL)
await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${bearerToken}` },
})
}
}
catch (error) {
// A network error cannot preserve local credentials. The server session
// expires by its TTL after the local credentials are removed.
console.error('Server sign-out failed', errorMessageFrom(error))
}
clearAuthState()
} }
/** /**
@@ -227,7 +307,7 @@ export const useAuthStore = defineStore('auth', () => {
* `refreshTokenNow()` early-return without attempting refresh, so 401s * `refreshTokenNow()` early-return without attempting refresh, so 401s
* loop silently until the user lands on a page that calls fetchSession. * loop silently until the user lands on a page that calls fetchSession.
*/ */
function clearAllAuthState(): void { function clearAuthState(): void {
stopRefreshTimer() stopRefreshTimer()
user.value = null user.value = null
session.value = null session.value = null
@@ -238,6 +318,10 @@ export const useAuthStore = defineStore('auth', () => {
idToken.value = null idToken.value = null
} }
async function clearAllAuthState(): Promise<void> {
clearAuthState()
}
const updateCredits = async () => { const updateCredits = async () => {
if (!isAuthenticated.value) if (!isAuthenticated.value)
return return
@@ -277,9 +361,24 @@ export const useAuthStore = defineStore('auth', () => {
oidcClientId, oidcClientId,
tokenExpiry, tokenExpiry,
scheduleTokenRefresh, scheduleTokenRefresh,
restoreRefreshSchedule, initialize,
completeSignIn,
fetchSession,
listSessions,
signOut,
refreshTokenNow, refreshTokenNow,
clearAllAuthState, clearAllAuthState,
onTokenRefreshed,
} }
}, {
synced: {
actions: [
'initialize',
'completeSignIn',
'fetchSession',
'signOut',
'refreshTokenNow',
'clearAllAuthState',
],
state: true,
},
}) })