From 2433e6137881b116884ced800d168b2b78e80219 Mon Sep 17 00:00:00 2001 From: Neko Date: Tue, 11 Aug 2026 22:42:03 +0800 Subject: [PATCH] fix(stage-ui): synchronize auth state across windows (#2256) --- apps/stage-pocket/src/modules/deep-links.ts | 3 +- apps/stage-tamagotchi/src/main/index.ts | 9 +- .../src/main/services/airi/auth.ts | 47 +---- .../src/main/windows/main/index.ts | 3 - .../main/windows/main/rpc/index.electron.ts | 4 +- .../src/main/windows/onboarding/index.ts | 4 +- .../src/main/windows/settings/index.ts | 3 - .../windows/settings/rpc/index.electron.ts | 4 +- .../bridges/electron-auth-callback.test.ts | 71 ++++++++ .../bridges/electron-auth-callback.ts | 17 +- apps/stage-web/src/pages/auth/callback.vue | 3 +- .../use-auth-provider-sync.test.ts | 24 ++- .../src/composables/use-auth-provider-sync.ts | 10 +- packages/stage-ui/src/libs/auth-client.ts | 42 +++++ packages/stage-ui/src/libs/auth-fetch.test.ts | 13 +- packages/stage-ui/src/libs/auth-fetch.ts | 13 +- packages/stage-ui/src/libs/auth.ts | 167 ++---------------- packages/stage-ui/src/stores/auth.test.ts | 60 +++++++ packages/stage-ui/src/stores/auth.ts | 155 +++++++++++++--- 19 files changed, 372 insertions(+), 280 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts create mode 100644 packages/stage-ui/src/libs/auth-client.ts diff --git a/apps/stage-pocket/src/modules/deep-links.ts b/apps/stage-pocket/src/modules/deep-links.ts index bedcd62d2..c07d41038 100644 --- a/apps/stage-pocket/src/modules/deep-links.ts +++ b/apps/stage-pocket/src/modules/deep-links.ts @@ -2,7 +2,7 @@ import type { URLOpenListenerEvent } from '@capacitor/app' import type { Router } from 'vue-router' 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' 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) await applyOIDCTokens(tokens, persisted.params.clientId) - await fetchSession() router.replace('/') } } diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index d730c4ca0..96fa455f3 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -28,7 +28,6 @@ import { createGlobalAppConfig } from './configs/global' import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { setElectronMainDirname } from './libs/electron/location' import { createI18n } from './libs/i18n' -import { createWindowAuthManagerService } from './services/airi/auth' import { setupServerChannel } from './services/airi/channel-server' import { setupGodotStageManager } from './services/airi/godot-stage' import { setupBuiltInServer } from './services/airi/http-server' @@ -180,8 +179,6 @@ app.whenReady().then(async () => { build: ({ dependsOn }) => setupExtensionHost(dependsOn), }) - const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService()) - const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService()) // 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 onboardingWindowManager = injeca.provide('windows:onboarding', { - dependsOn: { serverChannel, i18n, windowAuthManager }, + dependsOn: { serverChannel, i18n }, build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), }) @@ -220,7 +217,7 @@ app.whenReady().then(async () => { }) 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 }) => setupSettingsWindowReusableFunc({ ...dependsOn, @@ -229,7 +226,7 @@ app.whenReady().then(async () => { }) 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({ ...dependsOn, onWindowCreated: (window) => { diff --git a/apps/stage-tamagotchi/src/main/services/airi/auth.ts b/apps/stage-tamagotchi/src/main/services/airi/auth.ts index aed816277..c72536427 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/auth.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/auth.ts @@ -34,54 +34,13 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token' let closeLoopback: (() => void) | null = null 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() - - 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. */ export function createAuthService(params: { context: MainContext window: BrowserWindow - windowAuthManager: WindowAuthManager }): void { - params.windowAuthManager.registerWindow({ - context: params.context, - window: params.window, - }) - defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => { if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { return @@ -136,12 +95,12 @@ export function createAuthService(params: { loopback.result .then(async ({ code }) => { const tokens = await exchangeCode(code, codeVerifier, redirectUri) - params.windowAuthManager.broadcastAuthCallback(tokens) + params.context.emit(electronAuthCallback, tokens) log.log('OIDC token exchange successful') }) .catch((err) => { 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(() => { closeLoopback = null @@ -152,7 +111,7 @@ export function createAuthService(params: { closeLoopback = null signingInFlight = false 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' }) } }) diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 7558651f8..708736e77 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -2,7 +2,6 @@ import type { Rectangle } from 'electron' import type { InferOutput } from 'valibot' import type { I18n } from '../../libs/i18n' -import type { WindowAuthManager } from '../../services/airi/auth' import type { ServerChannel } from '../../services/airi/channel-server' import type { GodotStageManager } from '../../services/airi/godot-stage' import type { McpStdioManager } from '../../services/airi/mcp-servers' @@ -61,7 +60,6 @@ export async function setupMainWindow(params: { mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager - windowAuthManager: WindowAuthManager }) { const { setup: setupConfig, @@ -186,7 +184,6 @@ export async function setupMainWindow(params: { mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, onboardingWindowManager: params.onboardingWindowManager, - windowAuthManager: params.windowAuthManager, }) await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))) diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 1fb6f3e25..f38190978 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -1,7 +1,6 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' -import type { WindowAuthManager } from '../../../services/airi/auth' import type { ServerChannel } from '../../../services/airi/channel-server' import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { McpStdioManager } from '../../../services/airi/mcp-servers' @@ -40,7 +39,6 @@ export async function setupMainWindowElectronInvokes(params: { mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager - windowAuthManager: WindowAuthManager }) { // 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 @@ -55,7 +53,7 @@ export async function setupMainWindowElectronInvokes(params: { createMcpServersService({ context, manager: params.mcpStdioManager }) createGodotStageService({ context, manager: params.godotStageManager, window: 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, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index b1b093a9d..cbb1d71c9 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -1,5 +1,4 @@ import type { I18n } from '../../libs/i18n' -import type { WindowAuthManager } from '../../services/airi/auth' import type { ServerChannel } from '../../services/airi/channel-server' import { join, resolve } from 'node:path' @@ -28,7 +27,6 @@ export interface OnboardingWindowManager { export function setupOnboardingWindowManager(params: { serverChannel: ServerChannel i18n: I18n - windowAuthManager: WindowAuthManager }): OnboardingWindowManager { const closeCallbacks = new Set<() => void>() @@ -74,7 +72,7 @@ export function setupOnboardingWindowManager(params: { }) 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')) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index df52dc48f..2ffc7310e 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,5 +1,4 @@ import type { I18n } from '../../libs/i18n' -import type { WindowAuthManager } from '../../services/airi/auth' import type { ServerChannel } from '../../services/airi/channel-server' import type { GodotStageManager } from '../../services/airi/godot-stage' import type { McpStdioManager } from '../../services/airi/mcp-servers' @@ -37,7 +36,6 @@ export function setupSettingsWindowReusableFunc(params: { godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n - windowAuthManager: WindowAuthManager globalShortcut: GlobalShortcutService spotlightWindow: SpotlightWindowManager }): SettingsWindowManager { @@ -76,7 +74,6 @@ export function setupSettingsWindowReusableFunc(params: { godotStageManager: params.godotStageManager, mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, - windowAuthManager: params.windowAuthManager, globalShortcut: params.globalShortcut, spotlightWindow: params.spotlightWindow, }) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 66a9d04aa..3459d1184 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -1,7 +1,6 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' -import type { WindowAuthManager } from '../../../services/airi/auth' import type { ServerChannel } from '../../../services/airi/channel-server' import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { McpStdioManager } from '../../../services/airi/mcp-servers' @@ -40,7 +39,6 @@ export async function setupSettingsWindowInvokes(params: { godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n - windowAuthManager: WindowAuthManager globalShortcut: GlobalShortcutService spotlightWindow: SpotlightWindowManager }) { @@ -57,7 +55,7 @@ export async function setupSettingsWindowInvokes(params: { createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) 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. params.globalShortcut.registerWindow({ context, window: params.settingsWindow }) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts new file mode 100644 index 000000000..b3e06656b --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.test.ts @@ -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 Promise | void>()) + +vi.mock('@proj-airi/electron-vueuse', () => ({ + getElectronEventaContext: () => ({ + on: (event: object, handler: (event: { body?: unknown }) => Promise | 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) + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts index 5e84606ac..4c5fd874c 100644 --- a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts +++ b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts @@ -1,6 +1,5 @@ import { errorMessageFrom } from '@moeru/std' 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 { toast } from 'vue-sonner' @@ -23,18 +22,10 @@ export function initializeElectronAuthCallbackBridge() { return try { - const authStore = useAuthStore() - authStore.token = tokens.accessToken - - 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() + await useAuthStore().completeSignIn({ + ...tokens, + clientId: import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron', + }) } catch (error) { toast.error(errorMessageFrom(error) ?? 'Sign-in failed') diff --git a/apps/stage-web/src/pages/auth/callback.vue b/apps/stage-web/src/pages/auth/callback.vue index 378bb2e36..55bede964 100644 --- a/apps/stage-web/src/pages/auth/callback.vue +++ b/apps/stage-web/src/pages/auth/callback.vue @@ -1,7 +1,7 @@