fix(stage-ui): synchronize auth state across windows (#2256)
This commit is contained in:
@@ -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('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user