Files
wukuang/apps/lcdp/scripts/local-auth-proxy.mjs
2026-05-23 14:05:22 +08:00

460 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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)}`);
}