feat(desktop): implement client token rotation and session management enhancements
Desktop Client Build / Resolve Build Metadata (push) Successful in 27s
Desktop Client Build / Build Desktop Client (push) Successful in 22m14s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s

This commit is contained in:
Xu Liang
2026-05-08 11:43:25 +08:00
parent 73f1b2aca8
commit f38930b0ac
7 changed files with 317 additions and 6 deletions
@@ -6,6 +6,7 @@ import type {
DesktopClientInfo,
DesktopClientRegisterRequest,
DesktopClientRegisterResponse,
DesktopClientRotateResponse,
DesktopRuntimeSessionSyncRequest,
LoginRequest,
LoginResponse,
@@ -20,6 +21,7 @@ type SessionMode = 'authenticated' | 'preview'
interface DesktopSession {
mode: SessionMode
accessToken: string | null
accessTokenExpiresAt: number | null
refreshToken: string | null
user: UserInfo | null
clientToken: string | null
@@ -35,6 +37,9 @@ interface ResolvedDeviceInfo {
const sessionStorageKey = 'geo.desktop.session.v1'
const apiBaseURLStorageKey = 'geo.desktop.api-base-url'
const tokenRenewCheckMs = 60_000
const accessTokenRenewWindowMs = 5 * 60_000
const clientTokenRenewWindowMs = 3 * 24 * 60 * 60_000
export const DEFAULT_API_BASE_URL =
import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com'
const normalizedDefaultApiBaseURL = normalizeDesktopApiBaseURL(DEFAULT_API_BASE_URL)
@@ -45,6 +50,8 @@ const pending = shallowRef(false)
const error = shallowRef<string | null>(null)
let loginPromise: Promise<void> | null = null
let logoutPromise: Promise<void> | null = null
let renewPromise: Promise<void> | null = null
let tokenRenewTimer: ReturnType<typeof setInterval> | null = null
function readStoredSession(): DesktopSession | null {
if (typeof window === 'undefined') {
@@ -52,16 +59,49 @@ function readStoredSession(): DesktopSession | null {
}
try {
if (shouldDiscardStoredSessionForWindow()) {
window.localStorage.removeItem(sessionStorageKey)
return null
}
const raw = window.localStorage.getItem(sessionStorageKey)
if (!raw) {
return null
}
return JSON.parse(raw) as DesktopSession
return normalizeStoredSession(JSON.parse(raw))
} catch {
return null
}
}
function shouldDiscardStoredSessionForWindow(): boolean {
return new URLSearchParams(window.location.search).get('window') === 'login'
}
function normalizeStoredSession(value: unknown): DesktopSession | null {
if (!value || typeof value !== 'object') {
return null
}
const candidate = value as Partial<DesktopSession>
if (candidate.mode !== 'authenticated' && candidate.mode !== 'preview') {
return null
}
return {
mode: candidate.mode,
accessToken: typeof candidate.accessToken === 'string' ? candidate.accessToken : null,
accessTokenExpiresAt:
typeof candidate.accessTokenExpiresAt === 'number' ? candidate.accessTokenExpiresAt : null,
refreshToken: typeof candidate.refreshToken === 'string' ? candidate.refreshToken : null,
user: candidate.user ?? null,
clientToken: typeof candidate.clientToken === 'string' ? candidate.clientToken : null,
clientTokenExpiresAt:
typeof candidate.clientTokenExpiresAt === 'number' ? candidate.clientTokenExpiresAt : null,
desktopClient: candidate.desktopClient ?? null,
}
}
function persistSession(next: DesktopSession | null): void {
session.value = next
@@ -124,6 +164,7 @@ function createAuthenticatedClient() {
persistSession({
...session.value,
accessToken: tokens.accessToken,
accessTokenExpiresAt: tokens.expiresAt ?? session.value.accessTokenExpiresAt,
refreshToken: tokens.refreshToken,
})
},
@@ -137,6 +178,7 @@ function createAuthenticatedClient() {
return {
accessToken: next.access_token,
refreshToken: next.refresh_token,
expiresAt: next.expires_at,
}
},
},
@@ -170,12 +212,143 @@ function createSessionBoundAuthenticatedClient(source: DesktopSession) {
return {
accessToken: next.access_token,
refreshToken: next.refresh_token,
expiresAt: next.expires_at,
}
},
},
})
}
function expiresAtMs(value: number | null | undefined): number | null {
if (!value || !Number.isFinite(value)) {
return null
}
return value < 10_000_000_000 ? value * 1000 : value
}
function isRenewDue(value: number | null | undefined, renewWindowMs: number): boolean {
const expiresAt = expiresAtMs(value)
if (!expiresAt) {
return true
}
return expiresAt - Date.now() <= renewWindowMs
}
async function refreshLoginTokens(current: DesktopSession): Promise<DesktopSession> {
if (!current.refreshToken) {
throw new Error('missing_refresh_token')
}
const next = await createPublicClient().post<RefreshResponse, { refresh_token: string }>(
'/api/auth/refresh',
{ refresh_token: current.refreshToken },
)
return {
...current,
accessToken: next.access_token,
accessTokenExpiresAt: next.expires_at,
refreshToken: next.refresh_token,
}
}
async function rotateRuntimeClientToken(current: DesktopSession): Promise<DesktopSession> {
if (!current.clientToken || !current.desktopClient) {
throw new Error('missing_desktop_client_token')
}
const rotated = await window.desktopBridge?.app?.rotateRuntimeClientToken?.()
if (!rotated) {
throw new Error('desktop_client_rotate_unavailable')
}
return applyRotatedDesktopClient(current, rotated)
}
function applyRotatedDesktopClient(
current: DesktopSession,
rotated: DesktopClientRotateResponse,
): DesktopSession {
return {
...current,
clientToken: rotated.client_token,
clientTokenExpiresAt: rotated.expires_at,
desktopClient: rotated.client,
}
}
async function forceLogoutAfterRenewFailure(err: unknown): Promise<void> {
console.warn('[desktop-session] token renewal failed', err)
error.value = '登录状态已过期,请重新登录'
persistSession(null)
try {
await window.desktopBridge?.app?.releaseRuntimeSession?.(true)
} catch (releaseError) {
console.warn('[desktop-session] release after renewal failure failed', releaseError)
}
await transitionWindowForLogout()
}
async function renewAuthenticatedSession(options: { force?: boolean } = {}): Promise<void> {
const current = session.value
if (current?.mode !== 'authenticated') {
return
}
if (renewPromise) {
return renewPromise
}
renewPromise = (async () => {
let next = current
let expectedSession = current
const needsAccessRefresh =
options.force || isRenewDue(next.accessTokenExpiresAt, accessTokenRenewWindowMs)
const needsClientRotate =
options.force || isRenewDue(next.clientTokenExpiresAt, clientTokenRenewWindowMs)
if (!needsAccessRefresh && !needsClientRotate) {
return
}
if (needsAccessRefresh) {
next = await refreshLoginTokens(next)
if (session.value !== expectedSession) {
return
}
persistSession(next)
expectedSession = next
}
if (needsClientRotate) {
next = await rotateRuntimeClientToken(next)
if (session.value !== expectedSession) {
return
}
persistSession(next)
expectedSession = next
}
})()
.catch(async (err) => {
await forceLogoutAfterRenewFailure(err)
})
.finally(() => {
renewPromise = null
})
return renewPromise
}
function ensureTokenRenewTimer(): void {
if (typeof window === 'undefined' || tokenRenewTimer) {
return
}
tokenRenewTimer = setInterval(() => {
void renewAuthenticatedSession()
}, tokenRenewCheckMs)
}
function resolveFallbackPlatform(): string {
const currentNavigator = globalThis.navigator as Navigator & {
userAgentData?: { platform?: string }
@@ -431,6 +604,7 @@ async function performLogin(payload: LoginRequest): Promise<void> {
persistSession({
mode: 'authenticated',
accessToken: authData.access_token,
accessTokenExpiresAt: authData.expires_at,
refreshToken: authData.refresh_token,
user: authData.user,
clientToken: null,
@@ -443,6 +617,7 @@ async function performLogin(payload: LoginRequest): Promise<void> {
persistSession({
mode: 'authenticated',
accessToken: authData.access_token,
accessTokenExpiresAt: authData.expires_at,
refreshToken: authData.refresh_token,
user: authData.user,
clientToken: registered.client_token,
@@ -474,6 +649,7 @@ async function enterPreview(): Promise<void> {
persistSession({
mode: 'preview',
accessToken: null,
accessTokenExpiresAt: null,
refreshToken: null,
user: createPreviewUser(),
clientToken: 'preview-client-token',
@@ -541,6 +717,8 @@ if (typeof window !== 'undefined') {
}
})
void syncStoredDesktopClientDeviceInfo()
ensureTokenRenewTimer()
void renewAuthenticatedSession()
}
export function useDesktopSession() {
@@ -553,6 +731,7 @@ export function useDesktopSession() {
isPreviewMode: computed(() => session.value?.mode === 'preview'),
setApiBaseURL: persistApiBaseURL,
syncRuntimeSession: syncRuntimeSessionToMain,
renewSession: renewAuthenticatedSession,
login,
logout,
enterPreview,