实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
31
src/App.tsx
31
src/App.tsx
@@ -29,6 +29,7 @@ import { useOpencodeStore } from './stores/opencode';
|
||||
import { useProjectConfigStore } from './stores/project-config';
|
||||
import { AI_MODULE_SELECTION_PATH } from './lib/ai-modules';
|
||||
import { useUserSyncStore } from './stores/user-sync';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
|
||||
|
||||
/**
|
||||
@@ -243,6 +244,7 @@ function App() {
|
||||
const initAuth = useAuthStore((state) => state.init);
|
||||
const authInitialized = useAuthStore((state) => state.initialized);
|
||||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||||
const authAccessToken = useAuthStore((state) => state.accessToken);
|
||||
const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap);
|
||||
const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview;
|
||||
const authRequired = !skipSetupForE2E && !rendererOnlyPreview;
|
||||
@@ -252,6 +254,35 @@ function App() {
|
||||
void initAuth();
|
||||
}, [initAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHostEvent('auth:session-changed', (session) => {
|
||||
useAuthStore.getState().applyMainSession(session);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rendererOnlyPreview || !authInitialized || !authAccessToken) return;
|
||||
|
||||
const recordActivity = (event: Event) => {
|
||||
if (!event.isTrusted) return;
|
||||
void useAuthStore.getState().markActivity();
|
||||
};
|
||||
window.addEventListener('pointerdown', recordActivity, { passive: true });
|
||||
window.addEventListener('touchstart', recordActivity, { passive: true });
|
||||
window.addEventListener('wheel', recordActivity, { passive: true });
|
||||
window.addEventListener('keydown', recordActivity);
|
||||
window.addEventListener('focus', recordActivity);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', recordActivity);
|
||||
window.removeEventListener('touchstart', recordActivity);
|
||||
window.removeEventListener('wheel', recordActivity);
|
||||
window.removeEventListener('keydown', recordActivity);
|
||||
window.removeEventListener('focus', recordActivity);
|
||||
};
|
||||
}, [authAccessToken, authInitialized, rendererOnlyPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
initSettings();
|
||||
}, [initSettings]);
|
||||
|
||||
@@ -10,6 +10,7 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||
'oauth:error': 'oauth:error',
|
||||
'agent-browser:show': 'agent-browser:show',
|
||||
'agent-browser:state': 'agent-browser:state',
|
||||
'auth:session-changed': 'auth:session-changed',
|
||||
};
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
|
||||
@@ -5,6 +5,10 @@ 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';
|
||||
|
||||
export type AuthUser = {
|
||||
username: string;
|
||||
@@ -16,7 +20,6 @@ export type AuthUser = {
|
||||
|
||||
type AuthTokenPayload = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
token_type?: unknown;
|
||||
expires_in?: unknown;
|
||||
username?: unknown;
|
||||
@@ -31,8 +34,25 @@ type AuthActionResponse = {
|
||||
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 RefreshSessionOptions = {
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
type AuthState = {
|
||||
@@ -42,22 +62,43 @@ type AuthState = {
|
||||
authBase: string;
|
||||
clientId: string;
|
||||
accessToken: string | null;
|
||||
refreshToken: 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;
|
||||
init: () => Promise<void>;
|
||||
loginWithBrowser: () => Promise<void>;
|
||||
refreshSession: () => Promise<string | null>;
|
||||
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
|
||||
getValidAccessToken: () => Promise<string | null>;
|
||||
markActivity: () => Promise<void>;
|
||||
maintainSession: () => Promise<void>;
|
||||
applyMainSession: (session: unknown) => void;
|
||||
invalidateSession: (message?: string) => void;
|
||||
logout: () => Promise<void>;
|
||||
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(/\/+$/, '');
|
||||
@@ -77,65 +118,105 @@ function asAuthorities(value: unknown): string[] {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
|
||||
function getExpiresAt(expiresIn: unknown): number | null {
|
||||
const seconds = typeof expiresIn === 'number'
|
||||
? expiresIn
|
||||
: (typeof expiresIn === 'string' ? Number(expiresIn) : NaN);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Date.now() + seconds * 1000;
|
||||
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 createSessionFromToken(token: AuthTokenPayload, fallbackRefreshToken: string | null = null) {
|
||||
const accessToken = asString(token.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('Login response did not include access_token');
|
||||
}
|
||||
function isSessionIdle(lastActiveAt: number | null, nowMs = Date.now()): boolean {
|
||||
return lastActiveAt != null
|
||||
&& nowMs - lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asString(token.refresh_token) ?? fallbackRefreshToken,
|
||||
tokenType: asString(token.token_type) ?? 'Bearer',
|
||||
expiresAt: getExpiresAt(token.expires_in),
|
||||
user: {
|
||||
username: asString(token.username) ?? '',
|
||||
userId: asString(token.user_id),
|
||||
tenantId: asStringOrNumber(token.tenant_id),
|
||||
deptId: asStringOrNumber(token.dept_id),
|
||||
authorities: asAuthorities(token.authorities),
|
||||
},
|
||||
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,
|
||||
refreshToken: null,
|
||||
tokenType: null,
|
||||
expiresAt: null,
|
||||
lastActiveAt: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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;
|
||||
refreshToken: string | null;
|
||||
legacyRefreshToken: string | null;
|
||||
tokenType: string | null;
|
||||
expiresAt: number | null;
|
||||
}): Promise<boolean> {
|
||||
if (!session.accessToken) return false;
|
||||
lastActiveAt: number | null;
|
||||
}): Promise<MainSyncResult> {
|
||||
try {
|
||||
const response = await hostApiFetch<AuthActionResponse>('/api/auth/session/sync', {
|
||||
const response = await hostApiFetch<MainSessionResponse>('/api/auth/session/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
refreshToken: session.legacyRefreshToken,
|
||||
tokenType: session.tokenType,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
}),
|
||||
});
|
||||
return response.success;
|
||||
} catch {
|
||||
return false;
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,134 +233,326 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 expired = Boolean(
|
||||
state.accessToken
|
||||
&& state.expiresAt
|
||||
&& state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS,
|
||||
);
|
||||
set({
|
||||
initialized: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: DEFAULT_AUTH_BASE,
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...(expired || authBaseChanged ? getClearedSession() : {}),
|
||||
});
|
||||
const restoredLastActiveAt = state.accessToken
|
||||
? (state.lastActiveAt ?? nowMs)
|
||||
: null;
|
||||
|
||||
if (expired || authBaseChanged || !state.accessToken) {
|
||||
set({ initialized: true });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ loading: true });
|
||||
const synchronized = await syncMainSession({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
tokenType: state.tokenType,
|
||||
expiresAt: state.expiresAt,
|
||||
});
|
||||
if (!synchronized) {
|
||||
if (authBaseChanged) {
|
||||
advanceAuthSessionEpoch();
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: '登录状态恢复失败,请重新登录。',
|
||||
error: null,
|
||||
authBase: DEFAULT_AUTH_BASE,
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...getClearedSession(),
|
||||
});
|
||||
try {
|
||||
await hostApiFetch<AuthActionResponse>('/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;
|
||||
}
|
||||
set({ initialized: true, loading: false });
|
||||
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;
|
||||
}
|
||||
|
||||
set({ initialized: true, loading: false, error: null });
|
||||
},
|
||||
|
||||
loginWithBrowser: async () => {
|
||||
const authBase = trimTrailingSlash(DEFAULT_AUTH_BASE);
|
||||
const clientId = DEFAULT_CLIENT_ID;
|
||||
|
||||
advanceAuthSessionEpoch();
|
||||
const operationEpoch = authSessionEpoch;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/browser/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.success || !response.token) {
|
||||
const session = parseMainSession(response.session);
|
||||
if (!response.success || !response.token || !session) {
|
||||
throw new Error(response.error || 'Browser authorization failed');
|
||||
}
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
const session = createSessionFromToken(response.token);
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase,
|
||||
clientId,
|
||||
...session,
|
||||
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...sessionFieldsFromMain(session),
|
||||
user: createUserFromToken(response.token),
|
||||
});
|
||||
if (!await syncMainSession(session)) {
|
||||
throw new Error('Failed to synchronize the signed-in session');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
advanceAuthSessionEpoch();
|
||||
set({ loading: false, error: message, ...getClearedSession() });
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
},
|
||||
|
||||
refreshSession: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) return null;
|
||||
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<AuthTokenResponse>('/api/auth/refresh', {
|
||||
const response = await hostApiFetch<MainSessionResponse>('/api/auth/session/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
body: JSON.stringify({ forceRefresh: options.forceRefresh ?? true }),
|
||||
});
|
||||
|
||||
if (!response.success || !response.token) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
|
||||
const session = parseMainSession(response.session);
|
||||
if (!response.success || !session) {
|
||||
throw new Error(response.error || 'Refresh failed');
|
||||
}
|
||||
|
||||
const session = createSessionFromToken(response.token, refreshToken);
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
...session,
|
||||
...sessionFieldsFromMain(session),
|
||||
});
|
||||
if (!await syncMainSession(session)) {
|
||||
throw new Error('Failed to synchronize the refreshed session');
|
||||
}
|
||||
return session.accessToken;
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const terminal = isTerminalAuthError(error);
|
||||
if (terminal) advanceAuthSessionEpoch();
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: message,
|
||||
...getClearedSession(),
|
||||
...(terminal ? getClearedSession() : {}),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getValidAccessToken: async () => {
|
||||
const { accessToken, expiresAt, refreshToken } = get();
|
||||
const { accessToken, expiresAt, canRefresh } = get();
|
||||
const expired = Boolean(
|
||||
accessToken
|
||||
&& expiresAt
|
||||
&& expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS,
|
||||
);
|
||||
|
||||
if (!accessToken || expired) {
|
||||
return refreshToken ? await get().refreshSession() : null;
|
||||
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<AuthActionResponse>('/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<MainSessionResponse>('/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<AuthActionResponse>('/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,
|
||||
@@ -290,37 +563,55 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
logout: async () => {
|
||||
const { accessToken } = get();
|
||||
advanceAuthSessionEpoch();
|
||||
const operationEpoch = authSessionEpoch;
|
||||
set({ initialized: true, loading: false, error: null, ...getClearedSession() });
|
||||
try {
|
||||
if (accessToken) {
|
||||
await hostApiFetch<AuthActionResponse>('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accessToken }),
|
||||
});
|
||||
}
|
||||
await hostApiFetch<AuthActionResponse>('/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 });
|
||||
} finally {
|
||||
set({ loading: false, ...getClearedSession() });
|
||||
}
|
||||
},
|
||||
|
||||
isAuthenticated: () => {
|
||||
const { accessToken, expiresAt } = get();
|
||||
if (!accessToken) return false;
|
||||
if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) return false;
|
||||
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: 1,
|
||||
migrate: (persistedState: unknown) => {
|
||||
const state = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Record<string, unknown>
|
||||
: {};
|
||||
const legacyRefreshToken = asString(state.legacyRefreshToken)
|
||||
?? asString(state.refreshToken);
|
||||
const { refreshToken: _refreshToken, ...rest } = state;
|
||||
return {
|
||||
...rest,
|
||||
canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken),
|
||||
legacyRefreshToken,
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
authBase: state.authBase,
|
||||
clientId: state.clientId,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
tokenType: state.tokenType,
|
||||
expiresAt: state.expiresAt,
|
||||
lastActiveAt: state.lastActiveAt,
|
||||
canRefresh: state.canRefresh,
|
||||
legacyRefreshToken: state.legacyRefreshToken,
|
||||
user: state.user,
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user