diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 41628ce3f..bf1072193 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -66,7 +66,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { ? `${deps.env.API_SERVER_URL}/api/auth/oauth2/authorize?${oidcParams.toString()}` : '/' - if (provider === 'google' || provider === 'github') { + if (!!provider && ['google', 'github'].includes(provider)) { const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}` return c.redirect(socialUrl) } diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 038200120..1433fe5c4 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -171,6 +171,7 @@ "electron-builder": "^26.8.1", "electron-updater": "^6.8.3", "electron-vite": "^5.0.0", + "get-port-please": "catalog:", "h3": "^2.0.1-rc.19", "less": "^4.6.4", "mkcert": "catalog:", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index b5cd31988..d137d9409 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -23,6 +23,7 @@ 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 { setupMcpStdioManager } from './services/airi/mcp-servers' import { setupPluginHost } from './services/airi/plugins' @@ -121,13 +122,15 @@ app.whenReady().then(async () => { build: () => setupPluginHost(), }) + const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService()) + // BeatSync will create a background window to capture and process audio. const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync()) const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow()) const onboardingWindowManager = injeca.provide('windows:onboarding', { - dependsOn: { serverChannel, i18n }, + dependsOn: { serverChannel, i18n, windowAuthManager }, build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), }) @@ -157,7 +160,7 @@ app.whenReady().then(async () => { }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager }, + dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager }, build: async ({ dependsOn }) => setupMainWindow(dependsOn), }) diff --git a/apps/stage-tamagotchi/src/main/services/electron/auth.ts b/apps/stage-tamagotchi/src/main/services/airi/auth.ts similarity index 72% rename from apps/stage-tamagotchi/src/main/services/electron/auth.ts rename to apps/stage-tamagotchi/src/main/services/airi/auth.ts index 7175f9770..6dd3e224d 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auth.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/auth.ts @@ -4,7 +4,11 @@ import type { BrowserWindow } from 'electron' import { useLogg } from '@guiiai/logg' import { defineInvokeHandler } from '@moeru/eventa' import { errorMessageFrom } from '@moeru/std' -import { generateCodeChallenge, generateCodeVerifier, generateState } from '@proj-airi/stage-shared/auth' +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from '@proj-airi/stage-shared/auth' import { shell } from 'electron' import { @@ -13,7 +17,7 @@ import { electronAuthLogout, electronAuthStartLogin, } from '../../../shared/eventa' -import { startLoopbackServer } from './loopback-server' +import { startLoopbackServer } from '../electron/loopback-server' const log = useLogg('auth-service').useGlobalConfig() @@ -28,18 +32,40 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token' // Active loopback server cleanup handle let closeLoopback: (() => void) | null = null -let loginInFlight = false -const authContexts = new Set() +let signingInFlight = false -function emitAuthCallback(tokens: TokenExchangeResult): void { - for (const context of authContexts) { - context.emit(electronAuthCallback, tokens) - } +export interface WindowAuthManager { + registerWindow: (params: { context: MainContext, window: BrowserWindow }) => void + broadcastAuthCallback: (tokens: TokenExchangeResult) => void + broadcastAuthError: (error: string) => void } -function emitAuthError(error: string): void { - for (const context of authContexts) { - context.emit(electronAuthCallbackError, { error }) +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, } } @@ -49,10 +75,11 @@ function emitAuthError(error: string): void { export function createAuthService(params: { context: MainContext window: BrowserWindow + windowAuthManager: WindowAuthManager }): void { - authContexts.add(params.context) - params.window.on('closed', () => { - authContexts.delete(params.context) + params.windowAuthManager.registerWindow({ + context: params.context, + window: params.window, }) defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => { @@ -60,14 +87,14 @@ export function createAuthService(params: { return } - if (loginInFlight) { + if (signingInFlight) { log.withFields({ windowId: params.window.webContents.id }).warn('Replacing in-flight OIDC login attempt with a new request') closeLoopback?.() closeLoopback = null - loginInFlight = false + signingInFlight = false } - loginInFlight = true + signingInFlight = true try { // Clean up any previous in-flight login @@ -110,28 +137,28 @@ export function createAuthService(params: { .then(async ({ code, state: returnedState }) => { if (returnedState !== state) { log.warn('State mismatch — possible CSRF attack') - emitAuthError('State mismatch') + params.windowAuthManager.broadcastAuthError('State mismatch') return } const tokens = await exchangeCode(code, codeVerifier, redirectUri) - emitAuthCallback(tokens) + params.windowAuthManager.broadcastAuthCallback(tokens) log.log('OIDC token exchange successful') }) .catch((err) => { - log.withError(err).error('OIDC login failed') - emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed') + log.withError(err).error('OIDC signing in failed') + params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed') }) .finally(() => { closeLoopback = null - loginInFlight = false + signingInFlight = false }) } catch (err) { closeLoopback = null - loginInFlight = false - log.withError(err).error('Failed to start OIDC login flow') - emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed') + signingInFlight = false + log.withError(err).error('Failed to start OIDC signing in flow') + params.windowAuthManager.broadcastAuthError(errorMessageFrom(err) ?? 'OIDC signing in failed') } }) @@ -142,7 +169,7 @@ export function createAuthService(params: { closeLoopback?.() closeLoopback = null - loginInFlight = false + signingInFlight = false }) } diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 6bd361777..6b58098a7 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,5 +1,4 @@ export * from './app' -export * from './auth' export * from './auto-updater' export * from './powerMonitor' export * from './screen' diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index aae622ff1..66b7e1ea5 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -2,6 +2,7 @@ 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 { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' @@ -57,6 +58,7 @@ export async function setupMainWindow(params: { mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager + windowAuthManager: WindowAuthManager }) { const { setup: setupConfig, @@ -171,6 +173,7 @@ export async function setupMainWindow(params: { mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, onboardingWindowManager: params.onboardingWindowManager, + windowAuthManager: params.windowAuthManager, }) /** 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 502a9b33f..22f6226a7 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,6 +1,7 @@ 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 { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' @@ -14,11 +15,11 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' +import { createAuthService } from '../../../services/airi/auth' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createOnboardingService } from '../../../services/airi/onboarding' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' -import { createAuthService } from '../../../services/electron/auth' import { toggleWindowShow } from '../../shared' import { setupBaseWindowElectronInvokes } from '../../shared/window' @@ -33,6 +34,7 @@ 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 @@ -46,7 +48,7 @@ export async function setupMainWindowElectronInvokes(params: { createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager }) - createAuthService({ context, window: params.window }) + createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index 5f52b4a7c..8f10dd5b6 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -1,4 +1,5 @@ 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' @@ -14,7 +15,7 @@ import icon from '../../../../resources/icon.png?asset' import { electronOnboardingClose } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createReusableWindow } from '../../libs/electron/window-manager' -import { createAuthService } from '../../services/electron/auth' +import { createAuthService } from '../../services/airi/auth' import { toggleWindowShow } from '../shared' import { setupBaseWindowElectronInvokes } from '../shared/window' @@ -26,6 +27,7 @@ export interface OnboardingWindowManager { export function setupOnboardingWindowManager(params: { serverChannel: ServerChannel i18n: I18n + windowAuthManager: WindowAuthManager }): OnboardingWindowManager { async function getOnboardingWindow(getWindow: () => Promise) { const window = await getWindow() @@ -72,7 +74,7 @@ export function setupOnboardingWindowManager(params: { }) await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) - createAuthService({ context, window: newWindow }) + createAuthService({ context, window: newWindow, windowAuthManager: params.windowAuthManager }) await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding')) 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 711c89156..eb1c2e27f 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,6 +1,7 @@ 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 { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' @@ -12,10 +13,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa' +import { createAuthService } from '../../../services/airi/auth' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' -import { createAuthService } from '../../../services/electron/auth' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupSettingsWindowInvokes(params: { @@ -26,6 +27,7 @@ export async function setupSettingsWindowInvokes(params: { serverChannel: ServerChannel mcpStdioManager: McpStdioManager i18n: I18n + 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 @@ -39,7 +41,7 @@ export async function setupSettingsWindowInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) - createAuthService({ context, window: params.settingsWindow }) + createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => { diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue index a701e3423..a1b4ae2b6 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue @@ -22,10 +22,10 @@ const authStore = useAuthStore() const { isAuthenticated, user, needsLogin, credits } = storeToRefs(authStore) const context = useElectronEventaContext() -const startLogin = useElectronEventaInvoke(electronAuthStartLogin) +const startSigningIn = useElectronEventaInvoke(electronAuthStartLogin) const openSettings = useElectronEventaInvoke(electronOpenSettings) -const loggingIn = ref(false) +const signingIn = ref(false) const userName = computed(() => user.value?.name) const userAvatar = computed(() => user.value?.image) @@ -35,28 +35,28 @@ function handleClick() { openSettings({ route: '/settings/account' }) } else { - doLogin() + doSigningIn() } } -function doLogin() { - loggingIn.value = true - startLogin() +function doSigningIn() { + signingIn.value = true + startSigningIn() } // Clear loading state on callback or error from main process. // No cleanup needed — this component lives for the window's lifetime. context.value.on(electronAuthCallback, () => { - loggingIn.value = false + signingIn.value = false }) context.value.on(electronAuthCallbackError, () => { - loggingIn.value = false + signingIn.value = false }) // React to needsLogin from other components (e.g. onboarding) watch(needsLogin, (val) => { if (val && !isAuthenticated.value) { - doLogin() + doSigningIn() needsLogin.value = false } }) @@ -64,13 +64,13 @@ watch(needsLogin, (val) => { // Clear loading when authenticated watch(isAuthenticated, (val) => { if (val) - loggingIn.value = false + signingIn.value = false })