实现客户端登录七天滑动续期

需求:解决短效访问令牌到期后客户端一小时掉登录的问题。

实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
2026-08-07 16:11:10 +08:00
parent 7b23cce67a
commit 86ece3a430
20 changed files with 2941 additions and 326 deletions

View File

@@ -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]);

View File

@@ -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 {

View File

@@ -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,
}),
},