Files
wukuang/apps/lcdp/scripts/local-auth-proxy.mjs
T

460 lines
15 KiB
JavaScript
Raw Normal View History

2026-05-23 14:05:22 +08:00
/**
* @description 本地登录代理脚本:启动后会调用后端登录接口并缓存 accessToken,供前端调试读取。
*
* 使用方式:
* - 通过 package.json 脚本启动(推荐):
* - `pnpm dev`
* - `pnpm dev:pet`
* - `pnpm dev:qa`
* - 或直接启动:
* - `node ./scripts/local-auth-proxy.mjs --mode pet`
*
* 可配置项(优先级:process.env > .env/.env.<mode>):
* - `BUSINESS_RULES_MODE`:环境模式(默认 `pet`
* - `LOCAL_AUTH_PROXY_PORT`:代理服务端口(默认 `9530`)
* - `LOCAL_AUTH_PROFILE`:启动时指定登录 profile,未配置时读取本地配置中的 defaultProfile
* - `LOCAL_AUTH_CONFIG_PATH`:本地账号配置文件路径,默认 `./dev-tools/local-auth/config.local.json`
* - `VITE_API_BASE`:后端网关地址(必填)
*
* 代理接口:
* - `GET /__local_auth/access-token`
* - `GET /__local_auth/refresh-token`
* - `GET /__local_auth/state`
* - `GET /__local_auth/switch-profile?profile=<profileName>`
*/
import { readFile } from 'node:fs/promises';
import http from 'node:http';
import { dirname, relative, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
LOCAL_AUTH_ACCESS_TOKEN_PATH,
LOCAL_AUTH_PROXY_DEFAULT_PORT,
LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT,
LOCAL_AUTH_PROXY_SERVICE_ID,
LOCAL_AUTH_REFRESH_TOKEN_PATH,
LOCAL_AUTH_STATE_PATH,
LOCAL_AUTH_SWITCH_PROFILE_PATH,
} from './local-auth-shared.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkgRoot = resolve(__dirname, '..');
const localAuthDir = resolve(pkgRoot, 'dev-tools/local-auth');
const defaultLocalAuthConfigPath = resolve(localAuthDir, 'config.local.json');
const localAuthConfigPath = process.env.LOCAL_AUTH_CONFIG_PATH
? resolve(pkgRoot, process.env.LOCAL_AUTH_CONFIG_PATH)
: defaultLocalAuthConfigPath;
const localAuthExampleConfigPath = resolve(localAuthDir, 'config.example.json');
const proxyPort = Number(process.env.LOCAL_AUTH_PROXY_PORT || LOCAL_AUTH_PROXY_DEFAULT_PORT);
const cliArgs = process.argv.slice(2);
const modeFlagIndex = cliArgs.findIndex(arg => arg === '--mode');
const modeArg = modeFlagIndex >= 0 ? String(cliArgs[modeFlagIndex + 1] || '').trim() : '';
const mode = modeArg || String(process.env.BUSINESS_RULES_MODE || 'pet').trim() || 'pet';
const instanceId = String(process.env.LOCAL_AUTH_INSTANCE_ID || '').trim();
const loginPath = '/biz-gateway/base/foundation/api/auth/login';
/**
* @description 解析 .env 文件文本为键值对象。
* @param {string} source .env 文件原始文本。
* @returns {Record<string, string>} 解析后的环境变量映射。
*/
function parseEnvText(source) {
return source
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
.reduce((acc, line) => {
const equalIndex = line.indexOf('=');
if (equalIndex <= 0) {
return acc;
}
const key = line.slice(0, equalIndex).trim();
const value = line.slice(equalIndex + 1).trim();
acc[key] = value;
return acc;
}, /** @type {Record<string, string>} */ ({}));
}
/**
* @description 读取基础环境和 mode 环境,合并生成最终环境变量。
* @returns {Promise<Record<string, string>>} 当前模式生效的环境变量。
*/
async function loadBusinessRulesEnv() {
const baseEnvPath = resolve(pkgRoot, '.env');
const modeEnvPath = resolve(pkgRoot, `.env.${mode}`);
const [baseText, modeText] = await Promise.all([
readFile(baseEnvPath, 'utf8').catch(() => ''),
readFile(modeEnvPath, 'utf8').catch(() => ''),
]);
return {
...parseEnvText(baseText),
...parseEnvText(modeText),
};
}
/**
* @description 读取本地账号配置。
* @returns {Promise<{ config: { defaultProfile: string, profiles: Record<string, { userCode: string, password: string }> }, configPath: string } | null>} 账号配置;文件不存在时返回 null。
*/
async function loadLocalAuthConfig() {
let source = '';
try {
source = await readFile(localAuthConfigPath, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return null;
}
throw error;
}
let parsed;
try {
parsed = JSON.parse(source);
} catch (error) {
throw new Error(`local auth config parse failed: ${error instanceof Error ? error.message : String(error)}`);
}
const rawProfiles = parsed?.profiles;
if (!rawProfiles || typeof rawProfiles !== 'object' || Array.isArray(rawProfiles)) {
throw new Error(`local auth config invalid: "profiles" must be an object in ${localAuthConfigPath}`);
}
const profiles = Object.entries(rawProfiles).reduce((acc, [profileName, profile]) => {
const userCode = String(profile?.userCode || '').trim();
const password = String(profile?.password || '').trim();
if (!userCode || !password) {
throw new Error(`local auth config invalid: profile "${profileName}" must contain "userCode" and "password"`);
}
acc[profileName] = { userCode, password };
return acc;
}, /** @type {Record<string, { userCode: string, password: string }>} */ ({}));
if (!Object.keys(profiles).length) {
throw new Error(`local auth config invalid: no profiles found in ${localAuthConfigPath}`);
}
return {
config: {
defaultProfile: String(parsed?.defaultProfile || '').trim(),
profiles,
},
configPath: localAuthConfigPath,
};
}
/**
* @description 解析启动时使用的 profile 名称。
* @param {{ defaultProfile: string, profiles: Record<string, { userCode: string, password: string }> }} config 本地账号配置。
* @returns {string} 当前应使用的 profile。
*/
function resolveInitialProfileName(config) {
const preferredProfile = String(process.env.LOCAL_AUTH_PROFILE || '').trim();
if (preferredProfile) {
if (!config.profiles[preferredProfile]) {
throw new Error(`LOCAL_AUTH_PROFILE "${preferredProfile}" not found in current local auth config`);
}
return preferredProfile;
}
if (config.defaultProfile) {
if (!config.profiles[config.defaultProfile]) {
throw new Error(`defaultProfile "${config.defaultProfile}" not found in current local auth config`);
}
return config.defaultProfile;
}
return Object.keys(config.profiles)[0];
}
/**
* @description 启动登录流程并返回登录结果。
* @param {string} apiBase 远端网关基础域名。
* @param {{ userCode: string, password: string }} credential 登录凭据。
* @returns {Promise<any>} 后端登录接口返回 JSON。
*/
async function login(apiBase, credential) {
const url = new URL(loginPath, apiBase).toString();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'App-Id': 'app-cisdigital-base-foundation',
},
body: JSON.stringify({
userCode: credential.userCode,
password: credential.password,
}),
});
if (!response.ok) {
throw new Error(`login failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* @description 从不同响应结构中提取 accessToken。
* @param {any} payload 登录接口响应体。
* @returns {string} 提取到的 accessToken;无值时返回空字符串。
*/
function extractAccessToken(payload) {
return (
payload?.accessToken
?? payload?.data?.token
?? payload?.data?.accessToken
?? payload?.result?.accessToken
?? ''
);
}
const env = await loadBusinessRulesEnv();
const apiBase = env.VITE_API_BASE || '';
const authConfigResult = await loadLocalAuthConfig();
const authConfig = authConfigResult?.config || null;
const activeLocalAuthConfigPath = authConfigResult?.configPath || localAuthConfigPath;
const enabled = Boolean(authConfig);
const availableProfiles = authConfig ? Object.keys(authConfig.profiles) : [];
function toProjectRelativePath(filePath) {
return relative(pkgRoot, filePath) || '.';
}
if (enabled && !apiBase) {
throw new Error(`VITE_API_BASE is empty for mode "${mode}"`);
}
let accessToken = '';
let loginError = '';
let currentProfile = authConfig ? resolveInitialProfileName(authConfig) : '';
let lastAttemptProfile = currentProfile;
let listeningPort = proxyPort;
/**
* @description 使用指定 profile 重新登录并更新缓存 token。
* @param {string} profileName profile 名称。
* @returns {Promise<void>}
*/
async function refreshAccessToken(profileName) {
if (!authConfig) {
return;
}
const credential = authConfig.profiles[profileName];
if (!credential) {
throw new Error(`profile "${profileName}" not found`);
}
lastAttemptProfile = profileName;
const nextLoginResponse = await login(apiBase, credential);
const nextAccessToken = extractAccessToken(nextLoginResponse);
if (!nextAccessToken) {
throw new Error(`profile "${profileName}" login succeeded but accessToken is empty`);
}
accessToken = nextAccessToken;
currentProfile = profileName;
loginError = '';
console.log(`[local-auth-proxy] login success, profile: ${profileName}, accessToken exists: true`);
}
/**
* @description 获取当前代理状态。
* @returns {{
* enabled: boolean
* serviceId: string
* instanceId: string
* mode: string
* port: number
* apiBase: string
* accessToken: string
* accessTokenReady: boolean
* activeProfile: string
* lastAttemptProfile: string
* availableProfiles: string[]
* configPath: string
* exampleConfigPath: string
* error?: string
* }} 当前状态。
*/
function getProxyState() {
return {
enabled,
serviceId: LOCAL_AUTH_PROXY_SERVICE_ID,
instanceId,
mode,
port: listeningPort,
apiBase,
accessToken,
accessTokenReady: Boolean(accessToken),
activeProfile: currentProfile,
lastAttemptProfile,
availableProfiles,
configPath: toProjectRelativePath(activeLocalAuthConfigPath),
exampleConfigPath: toProjectRelativePath(localAuthExampleConfigPath),
error: loginError || undefined,
};
}
/**
* @description 写入 JSON 响应。
* @param {http.ServerResponse} res 响应对象。
* @param {number} statusCode 状态码。
* @param {unknown} payload 响应体。
*/
function writeJson(res, statusCode, payload) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(statusCode);
res.end(JSON.stringify(payload));
}
/**
* @description 在给定端口上启动服务;若端口被占用则自动向后探测可用端口。
* @param {http.Server} targetServer http 服务实例。
* @param {number} startPort 起始端口。
* @returns {Promise<number>} 实际监听端口。
*/
async function listenOnAvailablePort(targetServer, startPort) {
const maxAttempts = Math.max(1, LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT);
/**
* @param {number} port 目标端口。
* @returns {Promise<number>} 监听成功后的实际端口。
*/
function listenOnce(port) {
return new Promise((resolvePromise, rejectPromise) => {
const cleanup = () => {
targetServer.off('error', handleError);
targetServer.off('listening', handleListening);
};
function handleError(error) {
cleanup();
rejectPromise(error);
}
function handleListening() {
cleanup();
resolvePromise(port);
}
targetServer.once('error', handleError);
targetServer.once('listening', handleListening);
targetServer.listen(port, '0.0.0.0');
});
}
for (let offset = 0; offset < maxAttempts; offset += 1) {
const nextPort = startPort + offset;
try {
return await listenOnce(nextPort);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
continue;
}
throw error;
}
}
throw new Error(`no available local auth port found from ${startPort} to ${startPort + maxAttempts - 1}`);
}
try {
if (enabled) {
await refreshAccessToken(currentProfile);
}
} catch (error) {
loginError = error instanceof Error ? error.message : String(error);
console.error('[local-auth-proxy] login failed:', loginError);
}
const server = http.createServer((req, res) => {
void (async () => {
if (req.method === 'OPTIONS') {
writeJson(res, 204, {});
return;
}
const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_ACCESS_TOKEN_PATH) {
writeJson(res, loginError ? 500 : 200, getProxyState());
return;
}
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_REFRESH_TOKEN_PATH) {
if (!enabled) {
writeJson(res, 200, getProxyState());
return;
}
const profileName = requestUrl.searchParams.get('profile')?.trim() || currentProfile;
try {
await refreshAccessToken(profileName);
writeJson(res, 200, getProxyState());
} catch (error) {
loginError = error instanceof Error ? error.message : String(error);
console.error('[local-auth-proxy] refresh token failed:', loginError);
writeJson(res, 500, getProxyState());
}
return;
}
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_STATE_PATH) {
writeJson(res, 200, getProxyState());
return;
}
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_SWITCH_PROFILE_PATH) {
if (!enabled) {
writeJson(res, 200, getProxyState());
return;
}
const profileName = requestUrl.searchParams.get('profile')?.trim();
if (!profileName) {
writeJson(res, 400, {
...getProxyState(),
error: 'query param "profile" is required',
});
return;
}
try {
await refreshAccessToken(profileName);
writeJson(res, 200, getProxyState());
} catch (error) {
loginError = error instanceof Error ? error.message : String(error);
console.error('[local-auth-proxy] switch profile failed:', loginError);
writeJson(res, 500, getProxyState());
}
return;
}
writeJson(res, 404, { message: 'Not Found' });
})().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[local-auth-proxy] request failed:', errorMessage);
writeJson(res, 500, { ...getProxyState(), error: errorMessage });
});
});
listeningPort = await listenOnAvailablePort(server, proxyPort);
console.log(`[local-auth-proxy] listening on http://localhost:${listeningPort}`);
if (listeningPort !== proxyPort) {
console.log(`[local-auth-proxy] default port ${proxyPort} is busy, switched to ${listeningPort}`);
}
if (instanceId) {
console.log(`[local-auth-proxy] instance id: ${instanceId}`);
}
if (!enabled) {
console.log(`[local-auth-proxy] local auth disabled: config not found at ${toProjectRelativePath(localAuthConfigPath)}`);
console.log(`[local-auth-proxy] copy template from ${toProjectRelativePath(localAuthExampleConfigPath)} when needed`);
} else {
console.log(`[local-auth-proxy] config path: ${toProjectRelativePath(activeLocalAuthConfigPath)}`);
console.log(`[local-auth-proxy] active profile: ${currentProfile}`);
console.log(`[local-auth-proxy] available profiles: ${availableProfiles.join(', ')}`);
console.log(`[local-auth-proxy] accessToken exists: ${Boolean(accessToken)}`);
}