Files
makelore/electron/api/routes/auth.ts
brother7 86ece3a430 实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。

实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
2026-08-07 16:11:10 +08:00

624 lines
19 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'http';
import { createCipheriv } from 'node:crypto';
import { shell } from 'electron';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { NIANCODE_AUTH_CONFIG } from '../auth-config';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import {
clearWorksSquareSession,
commitWorksSquareSession,
commitWorksSquareSessionFromTokenPayload,
discardUnrestorableWorksSquareSession,
flushWorksSquareSessionPersistence,
getValidWorksSquareAccessToken,
getWorksSquareSessionRestoreStatus,
getWorksSquareSessionSnapshot,
markWorksSquareSessionActive,
retryWorksSquareSessionRestore,
} from '../../services/works-square-session';
import {
clearManagedWorksSquareRuntime,
clearManagedWorksSquareRuntimeBestEffort,
ensureManagedWorksSquareRuntimeClean,
} from '../../services/works-square-runtime';
import { logger } from '../../utils/logger';
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
type AuthClientInput = {
authBase?: unknown;
clientId?: unknown;
clientSecret?: unknown;
};
type PasswordLoginInput = AuthClientInput & {
username?: unknown;
password?: unknown;
code?: unknown;
randomStr?: unknown;
scope?: unknown;
};
type LogoutInput = {
authBase?: unknown;
accessToken?: unknown;
};
type SessionSyncInput = {
accessToken?: unknown;
refreshToken?: unknown;
tokenType?: unknown;
expiresAt?: unknown;
lastActiveAt?: unknown;
};
type SessionRefreshInput = {
forceRefresh?: unknown;
};
type DesktopAuthStartPayload = {
request_id?: unknown;
device_secret?: unknown;
authorize_url?: unknown;
poll_interval_seconds?: unknown;
};
type DesktopAuthTokenPayload = {
status?: unknown;
token?: unknown;
};
const DESKTOP_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`Missing ${field}`);
}
return value.trim();
}
function readOptionalString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function readOptionalTrimmedString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readOptionalNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 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)) {
throw new Error('authBase must start with http:// or https://');
}
return authBase;
}
function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string {
const apiBase = value.replace(/\/+$/, '');
if (!/^https?:\/\//i.test(apiBase)) {
throw new Error('Works Square API base URL must start with http:// or https://');
}
return apiBase;
}
function createWorksUrl(pathname: string): URL {
return new URL(`${normalizeWorksBase()}${pathname}`);
}
function createBasicAuthHeader(clientId: string, clientSecret: string): string {
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
function createTokenRequestBody(params: Record<string, string>): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
body.set(key, value);
}
return body;
}
function encryptPasswordForTokenEndpoint(password: string, encodeKey: string): string {
const key = Buffer.from(encodeKey, 'utf8');
if (![16, 24, 32].includes(key.length)) {
throw new Error('Auth password encode key must be 16, 24, or 32 bytes');
}
const cipher = createCipheriv(`aes-${key.length * 8}-cfb`, key, key);
return Buffer.concat([
cipher.update(password, 'utf8'),
cipher.final(),
]).toString('base64');
}
function shouldEncryptPasswordForClient(clientId: string): boolean {
return clientId !== 'customPC';
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function getErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
for (const field of ['msg', 'message', 'error_description', 'error']) {
const value = record[field];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
}
if (typeof payload === 'string' && payload.trim()) {
return payload;
}
return fallback;
}
function readRequiredPayloadString(
payload: Record<string, unknown>,
field: keyof DesktopAuthStartPayload,
): string {
return readRequiredString(payload[field], field);
}
function readPollIntervalMs(value: unknown): number {
const seconds = typeof value === 'number'
? value
: (typeof value === 'string' ? Number(value) : 2);
if (!Number.isFinite(seconds) || seconds < 0) return 2_000;
return Math.floor(seconds * 1000);
}
function delay(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function requestToken(
authBase: string,
clientId: string,
clientSecret: string,
body: URLSearchParams,
): Promise<{ ok: boolean; status: number; payload: unknown }> {
const response = await proxyAwareFetch(`${authBase}/oauth2/token`, {
method: 'POST',
headers: {
Authorization: createBasicAuthHeader(clientId, clientSecret),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
return {
ok: response.ok,
status: response.status,
payload: await readResponsePayload(response),
};
}
async function pollDesktopAuthToken(
requestId: string,
deviceSecret: string,
pollIntervalMs: number,
): Promise<unknown> {
const startedAt = Date.now();
while (Date.now() - startedAt < DESKTOP_AUTH_TIMEOUT_MS) {
const tokenUrl = createWorksUrl('/api/auth/desktop/token');
tokenUrl.searchParams.set('request_id', requestId);
tokenUrl.searchParams.set('device_secret', deviceSecret);
const response = await proxyAwareFetch(tokenUrl.toString(), { method: 'GET' });
const payload = await readResponsePayload(response) as DesktopAuthTokenPayload;
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Desktop authorization failed (${response.status})`));
}
if (payload.status === 'approved' && payload.token) {
return payload.token;
}
await delay(pollIntervalMs);
}
throw new Error('Authorization timed out');
}
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',
});
const payload = await readResponsePayload(response);
if (!response.ok) {
sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, {
success: false,
error: getErrorMessage(payload, `Desktop authorization start failed (${response.status})`),
});
return;
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Desktop authorization start returned an invalid payload');
}
const record = payload as Record<string, unknown>;
const requestId = readRequiredPayloadString(record, 'request_id');
const deviceSecret = readRequiredPayloadString(record, 'device_secret');
const authorizeUrl = readRequiredPayloadString(record, 'authorize_url');
const pollIntervalMs = readPollIntervalMs(record.poll_interval_seconds);
await shell.openExternal(authorizeUrl);
const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs);
let session = null;
if (token && typeof token === 'object' && !Array.isArray(token)) {
session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
}
sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session });
}
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);
const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret);
const username = readRequiredString(body.username, 'username');
const password = readRequiredString(body.password, 'password');
const scope = readOptionalString(body.scope, NIANCODE_AUTH_CONFIG.scope);
const code = readOptionalTrimmedString(body.code);
const randomStr = readOptionalTrimmedString(body.randomStr);
const tokenPassword = shouldEncryptPasswordForClient(clientId)
? encryptPasswordForTokenEndpoint(password, NIANCODE_AUTH_CONFIG.passwordEncodeKey)
: password;
const tokenRequestParams: Record<string, string> = {
grant_type: 'password',
username,
password: tokenPassword,
scope,
};
if (code && randomStr) {
tokenRequestParams.code = code;
tokenRequestParams.randomStr = randomStr;
}
const tokenResult = await requestToken(
authBase,
clientId,
clientSecret,
createTokenRequestBody(tokenRequestParams),
);
if (!tokenResult.ok) {
sendJson(res, tokenResult.status === 401 ? 401 : 502, {
success: false,
error: getErrorMessage(tokenResult.payload, `Login failed (${tokenResult.status})`),
});
return;
}
let session = null;
if (
tokenResult.payload
&& typeof tokenResult.payload === 'object'
&& !Array.isArray(tokenResult.payload)
) {
session = await commitWorksSquareSessionFromTokenPayload(
tokenResult.payload as WorksSquareTokenPayload,
);
}
sendJson(res, 200, {
success: true,
token: withoutRefreshToken(tokenResult.payload),
session,
});
}
async function handleSessionSync(
req: IncomingMessage,
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
if (!await ensureWorksSquareSessionRestored()) {
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
return;
}
try {
await flushWorksSquareSessionPersistence();
} catch (error) {
logger.warn('[auth] Session persistence is temporarily unavailable during sync', error);
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
return;
}
const body = await parseJsonBody<SessionSyncInput>(req);
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;
}
}
if (!session) {
sendJson(res, 200, { success: true, session: null });
return;
}
sendJson(res, 200, { success: true, session });
}
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 {
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) {
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 {
session = await markWorksSquareSessionActive();
} catch (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,
});
}
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 {
if (discardedUnrestorableSession) {
await clearManagedWorksSquareRuntime(ctx, undefined, true);
}
await ensureManagedWorksSquareRuntimeClean(ctx);
return true;
} catch (error) {
logger.error('[auth] Failed to clean the previous runtime before login', error);
sendJson(res, 503, {
success: false,
error: '上次登录的本地运行环境尚未清理完成,请稍后重试。',
});
return false;
}
}
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(
req: IncomingMessage,
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
const body = await parseJsonBody<LogoutInput>(req);
const authBase = normalizeAuthBase(body.authBase);
const rendererAccessToken = readOptionalTrimmedString(body.accessToken);
const accessToken = getWorksSquareSessionSnapshot()?.accessToken
?? readRequiredString(rendererAccessToken, 'accessToken');
let cleanupError: Error | null = null;
clearWorksSquareSession();
try {
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);
}
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 {
response = await proxyAwareFetch(`${authBase}/token/logout`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
} catch (error) {
if (cleanupError) {
sendJson(res, 500, {
success: false,
error: 'Failed to clear local AI runtime state',
});
return;
}
throw error;
}
if (cleanupError) {
sendJson(res, 500, {
success: false,
error: 'Failed to clear local AI runtime state',
});
return;
}
if (!response.ok) {
const payload = await readResponsePayload(response);
sendJson(res, response.status === 401 ? 401 : 502, {
success: false,
error: getErrorMessage(payload, `Logout failed (${response.status})`),
});
return;
}
sendJson(res, 200, { success: true });
}
export async function handleAuthRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (!url.pathname.startsWith('/api/auth')) {
return false;
}
try {
if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') {
await handleBrowserAuthorization(res, ctx);
return true;
}
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
await handlePasswordLogin(req, res, ctx);
return true;
}
if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') {
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;
}
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
await handleLogout(req, res, ctx);
return true;
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
return true;
} catch (error) {
sendJson(res, 400, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
return true;
}
}