实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
@@ -8,12 +8,21 @@ import { NIANCODE_AUTH_CONFIG } from '../auth-config';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
clearWorksSquareSession,
|
||||
storeWorksSquareSession,
|
||||
storeWorksSquareSessionFromTokenPayload,
|
||||
commitWorksSquareSession,
|
||||
commitWorksSquareSessionFromTokenPayload,
|
||||
discardUnrestorableWorksSquareSession,
|
||||
flushWorksSquareSessionPersistence,
|
||||
getValidWorksSquareAccessToken,
|
||||
getWorksSquareSessionRestoreStatus,
|
||||
getWorksSquareSessionSnapshot,
|
||||
markWorksSquareSessionActive,
|
||||
retryWorksSquareSessionRestore,
|
||||
} from '../../services/works-square-session';
|
||||
import { clearWorksSquareAIGatewayCredential } from '../../services/works-square-ai-gateway';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config';
|
||||
import {
|
||||
clearManagedWorksSquareRuntime,
|
||||
clearManagedWorksSquareRuntimeBestEffort,
|
||||
ensureManagedWorksSquareRuntimeClean,
|
||||
} from '../../services/works-square-runtime';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
|
||||
|
||||
@@ -31,10 +40,6 @@ type PasswordLoginInput = AuthClientInput & {
|
||||
scope?: unknown;
|
||||
};
|
||||
|
||||
type RefreshInput = AuthClientInput & {
|
||||
refreshToken?: unknown;
|
||||
};
|
||||
|
||||
type LogoutInput = {
|
||||
authBase?: unknown;
|
||||
accessToken?: unknown;
|
||||
@@ -45,6 +50,11 @@ type SessionSyncInput = {
|
||||
refreshToken?: unknown;
|
||||
tokenType?: unknown;
|
||||
expiresAt?: unknown;
|
||||
lastActiveAt?: unknown;
|
||||
};
|
||||
|
||||
type SessionRefreshInput = {
|
||||
forceRefresh?: unknown;
|
||||
};
|
||||
|
||||
type DesktopAuthStartPayload = {
|
||||
@@ -85,6 +95,16 @@ function readOptionalNumber(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOptionalBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function withoutRefreshToken(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
||||
const { refresh_token: _refreshToken, ...publicPayload } = payload as Record<string, unknown>;
|
||||
return publicPayload;
|
||||
}
|
||||
|
||||
function normalizeAuthBase(value: unknown = NIANCODE_AUTH_CONFIG.gatewayAuthUrl): string {
|
||||
const authBase = readOptionalString(value, NIANCODE_AUTH_CONFIG.gatewayAuthUrl).replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(authBase)) {
|
||||
@@ -231,7 +251,11 @@ async function pollDesktopAuthToken(
|
||||
throw new Error('Authorization timed out');
|
||||
}
|
||||
|
||||
async function handleBrowserAuthorization(res: ServerResponse): Promise<void> {
|
||||
async function handleBrowserAuthorization(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
||||
const response = await proxyAwareFetch(createWorksUrl('/api/auth/desktop/start').toString(), {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -257,13 +281,19 @@ async function handleBrowserAuthorization(res: ServerResponse): Promise<void> {
|
||||
|
||||
await shell.openExternal(authorizeUrl);
|
||||
const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs);
|
||||
let session = null;
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
|
||||
session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
|
||||
}
|
||||
sendJson(res, 200, { success: true, token });
|
||||
sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session });
|
||||
}
|
||||
|
||||
async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
async function handlePasswordLogin(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
||||
const body = await parseJsonBody<PasswordLoginInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId);
|
||||
@@ -303,91 +333,171 @@ async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): P
|
||||
return;
|
||||
}
|
||||
|
||||
let session = null;
|
||||
if (
|
||||
tokenResult.payload
|
||||
&& typeof tokenResult.payload === 'object'
|
||||
&& !Array.isArray(tokenResult.payload)
|
||||
) {
|
||||
storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload);
|
||||
session = await commitWorksSquareSessionFromTokenPayload(
|
||||
tokenResult.payload as WorksSquareTokenPayload,
|
||||
);
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, token: tokenResult.payload });
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
token: withoutRefreshToken(tokenResult.payload),
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRefresh(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<RefreshInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId);
|
||||
const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret);
|
||||
const refreshToken = readRequiredString(body.refreshToken, 'refreshToken');
|
||||
|
||||
const tokenResult = await requestToken(
|
||||
authBase,
|
||||
clientId,
|
||||
clientSecret,
|
||||
createTokenRequestBody({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!tokenResult.ok) {
|
||||
sendJson(res, tokenResult.status === 401 ? 401 : 502, {
|
||||
success: false,
|
||||
error: getErrorMessage(tokenResult.payload, `Refresh failed (${tokenResult.status})`),
|
||||
});
|
||||
async function handleSessionSync(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
tokenResult.payload
|
||||
&& typeof tokenResult.payload === 'object'
|
||||
&& !Array.isArray(tokenResult.payload)
|
||||
) {
|
||||
storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload, refreshToken);
|
||||
try {
|
||||
await flushWorksSquareSessionPersistence();
|
||||
} catch (error) {
|
||||
logger.warn('[auth] Session persistence is temporarily unavailable during sync', error);
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, token: tokenResult.payload });
|
||||
}
|
||||
|
||||
async function handleSessionSync(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<SessionSyncInput>(req);
|
||||
storeWorksSquareSession({
|
||||
accessToken: readRequiredString(body.accessToken, 'accessToken'),
|
||||
refreshToken: readOptionalTrimmedString(body.refreshToken),
|
||||
tokenType: readOptionalTrimmedString(body.tokenType),
|
||||
expiresAt: readOptionalNumber(body.expiresAt),
|
||||
});
|
||||
let session = getWorksSquareSessionSnapshot();
|
||||
const legacyAccessToken = readOptionalTrimmedString(body.accessToken);
|
||||
if (!session && legacyAccessToken) {
|
||||
try {
|
||||
await ensureManagedWorksSquareRuntimeClean(ctx);
|
||||
session = await commitWorksSquareSession({
|
||||
accessToken: legacyAccessToken,
|
||||
refreshToken: readOptionalTrimmedString(body.refreshToken),
|
||||
tokenType: readOptionalTrimmedString(body.tokenType),
|
||||
expiresAt: readOptionalNumber(body.expiresAt),
|
||||
lastActiveAt: readOptionalNumber(body.lastActiveAt),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[auth] Failed to persist a migrated Renderer session', error);
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true });
|
||||
if (!session) {
|
||||
sendJson(res, 200, { success: true, session: null });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, session });
|
||||
}
|
||||
|
||||
async function clearManagedWorksSquareRuntime(ctx: HostApiContext): Promise<void> {
|
||||
const errors: string[] = [];
|
||||
|
||||
async function handleSessionRefresh(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
const body = await parseJsonBody<SessionRefreshInput>(req);
|
||||
try {
|
||||
await ctx.imageWorkspace?.closeEventSessions?.();
|
||||
const accessToken = await getValidWorksSquareAccessToken({
|
||||
forceRefresh: readOptionalBoolean(body.forceRefresh, true),
|
||||
});
|
||||
const session = getWorksSquareSessionSnapshot();
|
||||
if (accessToken && session) {
|
||||
sendJson(res, 200, { success: true, session });
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal session refresh');
|
||||
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.warn('[auth] Failed to refresh the managed Works Square session', error);
|
||||
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionActivity(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
let session;
|
||||
try {
|
||||
await ctx.opencodeManager.stop();
|
||||
session = await markWorksSquareSessionActive();
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.warn('[auth] Failed to persist Works Square session activity', error);
|
||||
sendJson(res, 503, { success: false, error: '登录活动暂时无法保存,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'idle session activity');
|
||||
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
clearWorksSquareAIGatewayCredential();
|
||||
async function ensureWorksSquareSessionRestored(): Promise<boolean> {
|
||||
if (getWorksSquareSessionRestoreStatus() === 'unavailable') {
|
||||
await retryWorksSquareSessionRestore();
|
||||
}
|
||||
return getWorksSquareSessionRestoreStatus() === 'ready';
|
||||
}
|
||||
|
||||
async function ensureRuntimeReadyForLogin(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
let discardedUnrestorableSession = false;
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
if (!await discardUnrestorableWorksSquareSession()) {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
error: '登录状态暂时无法恢复,请稍后重试。',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
discardedUnrestorableSession = true;
|
||||
}
|
||||
try {
|
||||
await getProviderService().deleteAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
if (discardedUnrestorableSession) {
|
||||
await clearManagedWorksSquareRuntime(ctx, undefined, true);
|
||||
}
|
||||
await ensureManagedWorksSquareRuntimeClean(ctx);
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.error('[auth] Failed to clean the previous runtime before login', error);
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
error: '上次登录的本地运行环境尚未清理完成,请稍后重试。',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to clear managed Works Square runtime state: ${errors.join('; ')}`);
|
||||
}
|
||||
async function handleSessionClear(res: ServerResponse, ctx: HostApiContext): Promise<void> {
|
||||
const accessToken = getWorksSquareSessionSnapshot()?.accessToken;
|
||||
clearWorksSquareSession();
|
||||
await Promise.all([
|
||||
flushWorksSquareSessionPersistence(),
|
||||
clearManagedWorksSquareRuntime(ctx, accessToken),
|
||||
]);
|
||||
sendJson(res, 200, { success: true });
|
||||
}
|
||||
|
||||
async function handleLogout(
|
||||
@@ -397,16 +507,24 @@ async function handleLogout(
|
||||
): Promise<void> {
|
||||
const body = await parseJsonBody<LogoutInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const accessToken = readRequiredString(body.accessToken, 'accessToken');
|
||||
const rendererAccessToken = readOptionalTrimmedString(body.accessToken);
|
||||
const accessToken = getWorksSquareSessionSnapshot()?.accessToken
|
||||
?? readRequiredString(rendererAccessToken, 'accessToken');
|
||||
|
||||
let cleanupError: Error | null = null;
|
||||
clearWorksSquareSession();
|
||||
try {
|
||||
await clearManagedWorksSquareRuntime(ctx);
|
||||
await clearManagedWorksSquareRuntime(ctx, accessToken);
|
||||
} catch (error) {
|
||||
cleanupError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('[auth] Failed to clear managed Works Square runtime state during logout', cleanupError);
|
||||
}
|
||||
clearWorksSquareSession();
|
||||
try {
|
||||
await flushWorksSquareSessionPersistence();
|
||||
} catch (error) {
|
||||
cleanupError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('[auth] Failed to persist the local session clear during logout', cleanupError);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -459,22 +577,32 @@ export async function handleAuthRoutes(
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') {
|
||||
await handleBrowserAuthorization(res);
|
||||
await handleBrowserAuthorization(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
||||
await handlePasswordLogin(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/refresh' && req.method === 'POST') {
|
||||
await handleRefresh(req, res);
|
||||
await handlePasswordLogin(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') {
|
||||
await handleSessionSync(req, res);
|
||||
await handleSessionSync(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/refresh' && req.method === 'POST') {
|
||||
await handleSessionRefresh(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/activity' && req.method === 'POST') {
|
||||
await handleSessionActivity(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') {
|
||||
await handleSessionClear(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user