import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { hostApiFetch } from '@/lib/host-api'; import { NIANCODE_AUTH_CLIENT_ID, NIANCODE_AUTH_GATEWAY_URL, } from '../../shared/auth-public'; import { WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS, WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS, } from '../../shared/auth-session'; import { DEFAULT_MODULE_ACCESS, normalizeModuleAccess, type ModuleAccess, } from '../../shared/module-access'; export type AuthUser = { username: string; userId: string | null; tenantId: string | number | null; deptId: string | number | null; authorities: string[]; }; type AuthTokenPayload = { access_token?: unknown; token_type?: unknown; expires_in?: unknown; username?: unknown; user_id?: unknown; tenant_id?: unknown; dept_id?: unknown; authorities?: unknown; }; type AuthActionResponse = { success: boolean; error?: string; }; type MainSession = { accessToken: string; tokenType: string; expiresAt: number | null; lastActiveAt: number; canRefresh: boolean; }; type AuthTokenResponse = AuthActionResponse & { token?: AuthTokenPayload; session?: MainSession | null; }; type MainSessionResponse = AuthActionResponse & { session?: MainSession | null; }; type ModuleAccessResponse = AuthActionResponse & { moduleAccess?: unknown; }; type RefreshSessionOptions = { forceRefresh?: boolean; }; type AuthState = { initialized: boolean; loading: boolean; error: string | null; authBase: string; clientId: string; accessToken: string | null; tokenType: string | null; expiresAt: number | null; lastActiveAt: number | null; canRefresh: boolean; /** One-release bridge for moving old Renderer-persisted refresh tokens into Main. */ legacyRefreshToken: string | null; user: AuthUser | null; moduleAccess: ModuleAccess; init: () => Promise; loginWithBrowser: () => Promise; refreshSession: (options?: RefreshSessionOptions) => Promise; getValidAccessToken: () => Promise; markActivity: () => Promise; maintainSession: () => Promise; applyMainSession: (session: unknown) => void; invalidateSession: (message?: string) => void; logout: () => Promise; isAuthenticated: () => boolean; }; type MainSyncResult = | { kind: 'success'; session: MainSession | null } | { kind: 'terminal' } | { kind: 'unavailable' }; const DEFAULT_CLIENT_ID = NIANCODE_AUTH_CLIENT_ID; const DEFAULT_AUTH_BASE = NIANCODE_AUTH_GATEWAY_URL; const TOKEN_EXPIRY_SKEW_MS = 30_000; const IDLE_LOGIN_MESSAGE = '登录已超过 7 天未使用,请重新授权。'; let authSessionEpoch = 0; function advanceAuthSessionEpoch(): void { authSessionEpoch += 1; } function isCurrentAuthSessionEpoch(epoch: number): boolean { return epoch === authSessionEpoch; } function trimTrailingSlash(value: string): string { return value.trim().replace(/\/+$/, ''); } function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function asStringOrNumber(value: unknown): string | number | null { if (typeof value === 'number' && Number.isFinite(value)) return value; return asString(value); } function asAuthorities(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === 'string' && item.length > 0); } function createUserFromToken(token: AuthTokenPayload): AuthUser { return { username: asString(token.username) ?? '', userId: asString(token.user_id), tenantId: asStringOrNumber(token.tenant_id), deptId: asStringOrNumber(token.dept_id), authorities: asAuthorities(token.authorities), }; } function isSessionIdle(lastActiveAt: number | null, nowMs = Date.now()): boolean { return lastActiveAt != null && nowMs - lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS; } function isTerminalAuthError(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const candidate = error as { code?: unknown; details?: { status?: unknown }; }; const message = error instanceof Error ? error.message.toLowerCase() : ''; return candidate.code === 'AUTH_INVALID' || candidate.details?.status === 401 || message.includes('invalid refresh token') || message.includes('unauthorized'); } function getClearedSession() { return { accessToken: null, tokenType: null, expiresAt: null, lastActiveAt: null, canRefresh: false, legacyRefreshToken: null, user: null, moduleAccess: { ...DEFAULT_MODULE_ACCESS }, }; } function sessionFieldsFromMain(session: MainSession) { return { accessToken: session.accessToken, tokenType: session.tokenType, expiresAt: session.expiresAt, lastActiveAt: session.lastActiveAt, canRefresh: session.canRefresh, legacyRefreshToken: null, }; } function parseMainSession(value: unknown): MainSession | null | undefined { if (value === null) return null; if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const session = value as Record; if ( typeof session.accessToken !== 'string' || !session.accessToken.trim() || typeof session.tokenType !== 'string' || (session.expiresAt !== null && typeof session.expiresAt !== 'number') || typeof session.lastActiveAt !== 'number' || typeof session.canRefresh !== 'boolean' ) { return undefined; } return { accessToken: session.accessToken, tokenType: session.tokenType, expiresAt: session.expiresAt as number | null, lastActiveAt: session.lastActiveAt, canRefresh: session.canRefresh, }; } async function syncMainSession(session: { accessToken: string | null; legacyRefreshToken: string | null; tokenType: string | null; expiresAt: number | null; lastActiveAt: number | null; }): Promise { try { const response = await hostApiFetch('/api/auth/session/sync', { method: 'POST', body: JSON.stringify({ accessToken: session.accessToken, refreshToken: session.legacyRefreshToken, tokenType: session.tokenType, expiresAt: session.expiresAt, lastActiveAt: session.lastActiveAt, }), }); if (!response.success) return { kind: 'unavailable' }; const parsed = parseMainSession(response.session ?? null); return parsed === undefined ? { kind: 'unavailable' } : { kind: 'success', session: parsed }; } catch (error) { return { kind: isTerminalAuthError(error) ? 'terminal' : 'unavailable' }; } } async function readCurrentModuleAccess(fallback: ModuleAccess): Promise { try { const response = await hostApiFetch('/api/auth/me'); if (!response.success) return fallback; return normalizeModuleAccess(response.moduleAccess); } catch (error) { if (isTerminalAuthError(error)) throw error; return fallback; } } export const useAuthStore = create()( persist( (set, get) => ({ initialized: false, loading: false, error: null, authBase: DEFAULT_AUTH_BASE, clientId: DEFAULT_CLIENT_ID, ...getClearedSession(), init: async () => { const state = get(); if (state.loading && !state.initialized) return; const operationEpoch = authSessionEpoch; const nowMs = Date.now(); const authBaseChanged = Boolean( state.authBase && trimTrailingSlash(state.authBase) !== trimTrailingSlash(DEFAULT_AUTH_BASE), ); const restoredLastActiveAt = state.accessToken ? (state.lastActiveAt ?? nowMs) : null; if (authBaseChanged) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: null, authBase: DEFAULT_AUTH_BASE, clientId: DEFAULT_CLIENT_ID, ...getClearedSession(), }); try { await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); } catch { // Main rejects sessions from a different configured gateway when restoring its store. } return; } set({ initialized: false, loading: true, error: null, authBase: DEFAULT_AUTH_BASE, clientId: DEFAULT_CLIENT_ID, lastActiveAt: restoredLastActiveAt, canRefresh: state.canRefresh || Boolean(state.legacyRefreshToken), }); const synchronized = await syncMainSession({ accessToken: state.accessToken, legacyRefreshToken: state.legacyRefreshToken, tokenType: state.tokenType, expiresAt: state.expiresAt, lastActiveAt: restoredLastActiveAt, }); if (!isCurrentAuthSessionEpoch(operationEpoch)) return; if (synchronized.kind === 'terminal') { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } if (synchronized.kind === 'unavailable') { set({ initialized: true, loading: false }); return; } if (!synchronized.session) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: null, ...getClearedSession() }); return; } set({ initialized: false, loading: true, error: null, ...sessionFieldsFromMain(synchronized.session), }); const session = synchronized.session; const needsRefresh = Boolean( session.expiresAt && session.expiresAt <= nowMs + TOKEN_EXPIRY_SKEW_MS, ); if (needsRefresh) { if (!session.canRefresh) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } await get().refreshSession({ forceRefresh: false }); return; } let moduleAccess: ModuleAccess; try { moduleAccess = await readCurrentModuleAccess( normalizeModuleAccess(state.moduleAccess), ); } catch (error) { if (!isCurrentAuthSessionEpoch(operationEpoch)) return; if (isTerminalAuthError(error)) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } moduleAccess = normalizeModuleAccess(state.moduleAccess); } if (!isCurrentAuthSessionEpoch(operationEpoch)) return; set({ initialized: true, loading: false, error: null, moduleAccess }); }, loginWithBrowser: async () => { advanceAuthSessionEpoch(); const operationEpoch = authSessionEpoch; set({ loading: true, error: null }); try { const response = await hostApiFetch('/api/auth/browser/start', { method: 'POST', }); const session = parseMainSession(response.session); if (!response.success || !response.token || !session) { throw new Error(response.error || 'Browser authorization failed'); } if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS }); if (!isCurrentAuthSessionEpoch(operationEpoch)) return; set({ initialized: true, loading: false, error: null, authBase: trimTrailingSlash(DEFAULT_AUTH_BASE), clientId: DEFAULT_CLIENT_ID, ...sessionFieldsFromMain(session), user: createUserFromToken(response.token), moduleAccess, }); } catch (error) { if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const terminal = isTerminalAuthError(error); const message = terminal ? '登录已过期,请重新授权。' : (error instanceof Error ? error.message : String(error)); advanceAuthSessionEpoch(); set({ loading: false, error: message, ...getClearedSession() }); throw new Error(message, { cause: error }); } }, refreshSession: async (options = {}) => { const state = get(); if (!state.canRefresh) return null; const operationEpoch = authSessionEpoch; const nowMs = Date.now(); if (isSessionIdle(state.lastActiveAt, nowMs)) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: IDLE_LOGIN_MESSAGE, ...getClearedSession(), }); return null; } try { const response = await hostApiFetch('/api/auth/session/refresh', { method: 'POST', body: JSON.stringify({ forceRefresh: options.forceRefresh ?? true }), }); if (!isCurrentAuthSessionEpoch(operationEpoch)) return null; const session = parseMainSession(response.session); if (!response.success || !session) { throw new Error(response.error || 'Refresh failed'); } const moduleAccess = await readCurrentModuleAccess(state.moduleAccess); if (!isCurrentAuthSessionEpoch(operationEpoch)) return null; set({ initialized: true, loading: false, error: null, ...sessionFieldsFromMain(session), moduleAccess, }); return session.accessToken; } catch (error) { if (!isCurrentAuthSessionEpoch(operationEpoch)) return null; const terminal = isTerminalAuthError(error); const message = terminal ? '登录已过期,请重新授权。' : (error instanceof Error ? error.message : String(error)); if (terminal) advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: message, ...(terminal ? getClearedSession() : {}), }); return null; } }, getValidAccessToken: async () => { const { accessToken, expiresAt, canRefresh } = get(); const expired = Boolean( accessToken && expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, ); if (!accessToken || expired) { return canRefresh ? await get().refreshSession({ forceRefresh: false }) : null; } return accessToken; }, markActivity: async () => { const state = get(); if (!state.accessToken) return; const operationEpoch = authSessionEpoch; const nowMs = Date.now(); if (isSessionIdle(state.lastActiveAt, nowMs)) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: IDLE_LOGIN_MESSAGE, ...getClearedSession(), }); try { await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); } catch { // Main also enforces the idle deadline before every token use. } return; } const shouldSync = state.lastActiveAt == null || nowMs - state.lastActiveAt >= WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS; if (!shouldSync) return; set({ lastActiveAt: nowMs }); try { const response = await hostApiFetch('/api/auth/session/activity', { method: 'POST', }); if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const session = parseMainSession(response.session); if (response.success && session) { set(sessionFieldsFromMain(session)); await get().maintainSession(); } } catch (error) { if (!isCurrentAuthSessionEpoch(operationEpoch)) return; if (isTerminalAuthError(error)) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); } } }, maintainSession: async () => { let state = get(); if (!state.accessToken) return; const operationEpoch = authSessionEpoch; if (state.legacyRefreshToken) { const synchronized = await syncMainSession({ accessToken: state.accessToken, legacyRefreshToken: state.legacyRefreshToken, tokenType: state.tokenType, expiresAt: state.expiresAt, lastActiveAt: state.lastActiveAt, }); if (!isCurrentAuthSessionEpoch(operationEpoch)) return; if (synchronized.kind === 'unavailable') return; if (synchronized.kind === 'terminal' || !synchronized.session) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } set(sessionFieldsFromMain(synchronized.session)); state = get(); } if (isSessionIdle(state.lastActiveAt)) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: IDLE_LOGIN_MESSAGE, ...getClearedSession(), }); try { await hostApiFetch('/api/auth/session/clear', { method: 'POST' }); } catch { // Main still rejects the stale session on its next guarded token access. } return; } const nearExpiry = Boolean( state.expiresAt && state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, ); if (!nearExpiry) return; if (!state.canRefresh) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: '登录已过期,请重新授权。', ...getClearedSession(), }); return; } await get().refreshSession({ forceRefresh: false }); }, applyMainSession: (value) => { const session = parseMainSession(value); if (session === undefined) return; if (session === null) { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, ...getClearedSession() }); return; } set({ error: null, ...sessionFieldsFromMain(session) }); }, invalidateSession: (message = '登录已过期,请重新登录。') => { advanceAuthSessionEpoch(); set({ initialized: true, loading: false, error: message, ...getClearedSession(), }); }, logout: async () => { const { accessToken } = get(); advanceAuthSessionEpoch(); const operationEpoch = authSessionEpoch; set({ initialized: true, loading: false, error: null, ...getClearedSession() }); try { await hostApiFetch('/api/auth/logout', { method: 'POST', body: JSON.stringify({ accessToken }), }); } catch (error) { if (!isCurrentAuthSessionEpoch(operationEpoch)) return; const message = error instanceof Error ? error.message : String(error); set({ error: message }); } }, isAuthenticated: () => { const { accessToken, expiresAt, lastActiveAt, canRefresh } = get(); if (!accessToken || isSessionIdle(lastActiveAt)) return false; if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) { return canRefresh; } return true; }, }), { name: 'niancode-auth', version: 2, migrate: (persistedState: unknown) => { const state = persistedState && typeof persistedState === 'object' ? persistedState as Record : {}; const legacyRefreshToken = asString(state.legacyRefreshToken) ?? asString(state.refreshToken); const { refreshToken: _refreshToken, ...rest } = state; return { ...rest, canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken), legacyRefreshToken, moduleAccess: normalizeModuleAccess(state.moduleAccess), }; }, partialize: (state) => ({ authBase: state.authBase, clientId: state.clientId, accessToken: state.accessToken, tokenType: state.tokenType, expiresAt: state.expiresAt, lastActiveAt: state.lastActiveAt, canRefresh: state.canRefresh, legacyRefreshToken: state.legacyRefreshToken, user: state.user, moduleAccess: state.moduleAccess, }), }, ), );