Files
memind/wechat-oauth.mjs
T
John 6ee6fd64dd Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:09:38 -07:00

439 lines
14 KiB
JavaScript

import crypto from 'node:crypto';
import { fetch } from 'undici';
const OAUTH_AUTHORIZE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize';
const OAUTH_QRCONNECT_URL = 'https://open.weixin.qq.com/connect/qrconnect';
const OAUTH_ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
const OAUTH_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
const STATE_TTL_MS = 10 * 60 * 1000;
const VALID_INTENTS = new Set(['login', 'register', 'bind']);
const VALID_AUTH_MODES = new Set(['mp', 'open', 'scan']);
function isPollScanAuthMode(authMode) {
return authMode === 'open' || authMode === 'scan';
}
function resolveCallbackUrl(env = process.env) {
const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? '';
if (explicit) return explicit;
const base = env.H5_PUBLIC_BASE_URL?.trim() ?? '';
if (base) return `${base.replace(/\/$/, '')}/auth/wechat/callback`;
return '';
}
export function isWechatUserAgent(userAgent = '') {
const ua = userAgent || '';
return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua);
}
export function loadWechatOAuthConfig(env = process.env) {
const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === '1';
const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? '';
const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? '';
const openAppId = env.H5_WECHAT_OPEN_APP_ID?.trim() ?? '';
const openAppSecret = env.H5_WECHAT_OPEN_APP_SECRET?.trim() ?? '';
const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo';
const callbackUrl = resolveCallbackUrl(env);
const configured = Boolean(appId && appSecret && callbackUrl);
const scanConfigured = Boolean(openAppId && openAppSecret && callbackUrl);
return {
enabled: enabledFlag && configured,
appId,
appSecret,
openAppId,
openAppSecret,
scanEnabled: enabledFlag && scanConfigured,
scope,
callbackUrl,
};
}
export function sanitizeWechatReturnTo(returnTo, req) {
if (!returnTo || typeof returnTo !== 'string') return '/';
const host = req.get('host');
try {
const url = new URL(returnTo, `http://${host}`);
if (url.host !== host) return '/';
if (!url.pathname.startsWith('/')) return '/';
return `${url.pathname}${url.search}${url.hash}`;
} catch {
if (returnTo.startsWith('/') && !returnTo.startsWith('//')) return returnTo;
return '/';
}
}
function normalizeIntent(intent) {
const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login';
return VALID_INTENTS.has(value) ? value : 'login';
}
function normalizeAuthMode(authMode) {
const value = typeof authMode === 'string' ? authMode.trim().toLowerCase() : 'mp';
return VALID_AUTH_MODES.has(value) ? value : 'mp';
}
function buildAuthorizeUrl(config, { state, authMode = 'mp' }) {
const isOpen = authMode === 'open';
const useMp = authMode === 'mp' || authMode === 'scan';
const params = new URLSearchParams({
appid: isOpen ? config.openAppId : config.appId,
redirect_uri: config.callbackUrl,
response_type: 'code',
scope: isOpen ? 'snsapi_login' : config.scope,
state,
});
const base = isOpen ? OAUTH_QRCONNECT_URL : OAUTH_AUTHORIZE_URL;
return `${base}?${params.toString()}#wechat_redirect`;
}
async function exchangeCode(config, code, authMode = 'mp') {
const isOpen = authMode === 'open';
const params = new URLSearchParams({
appid: isOpen ? config.openAppId : config.appId,
secret: isOpen ? config.openAppSecret : config.appSecret,
code,
grant_type: 'authorization_code',
});
const res = await fetch(`${OAUTH_ACCESS_TOKEN_URL}?${params.toString()}`);
const data = await res.json();
if (data.errcode) {
throw new Error(data.errmsg || `微信授权失败 (${data.errcode})`);
}
if (!data.openid) {
throw new Error('微信授权未返回 openid');
}
return {
accessToken: data.access_token,
openid: data.openid,
unionid: data.unionid ?? null,
refreshToken: data.refresh_token ?? null,
appId: isOpen ? config.openAppId : config.appId,
};
}
async function fetchUserInfo(accessToken, openid) {
const params = new URLSearchParams({
access_token: accessToken,
openid,
lang: 'zh_CN',
});
const res = await fetch(`${OAUTH_USERINFO_URL}?${params.toString()}`);
const data = await res.json();
if (data.errcode) {
throw new Error(data.errmsg || `获取微信用户信息失败 (${data.errcode})`);
}
return {
nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, 128) : null,
avatarUrl: typeof data.headimgurl === 'string' ? data.headimgurl.slice(0, 512) : null,
unionid: data.unionid ?? null,
};
}
export function createWechatOAuthService(pool, config, { userAuth } = {}) {
if (!config?.enabled) {
return {
enabled: false,
publicConfig() {
return { enabled: false, inWechat: false, scanEnabled: false };
},
};
}
const pruneExpiredStates = async (now = Date.now()) => {
if (!pool) return;
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]);
};
const createState = async ({
returnTo = '/',
utmSource = null,
utmMedium = null,
utmCampaign = null,
intent = 'login',
bindUserId = null,
authMode = 'mp',
now = Date.now(),
} = {}) => {
const state = crypto.randomBytes(24).toString('base64url');
if (pool) {
await pruneExpiredStates(now);
await pool.query(
`INSERT INTO h5_wechat_oauth_states
(state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
auth_mode, status, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
[
state,
returnTo,
utmSource,
utmMedium,
utmCampaign,
normalizeIntent(intent),
bindUserId,
normalizeAuthMode(authMode),
now + STATE_TTL_MS,
now,
],
);
}
return state;
};
const loadState = async (state, now = Date.now()) => {
if (!state || !pool) return null;
const [rows] = await pool.query(
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
auth_mode, status, result_kind, result_token, result_message, expires_at
FROM h5_wechat_oauth_states
WHERE state = ?
LIMIT 1`,
[state],
);
const row = rows[0];
if (!row || Number(row.expires_at) <= now) return null;
return row;
};
const consumeState = async (state, now = Date.now()) => {
const row = await loadState(state, now);
if (!row) return null;
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
return {
returnTo: row.return_to || '/',
utmSource: row.utm_source,
utmMedium: row.utm_medium,
utmCampaign: row.utm_campaign,
intent: row.intent || 'login',
bindUserId: row.bind_user_id,
authMode: row.auth_mode || 'mp',
};
};
const markStateComplete = async (
state,
{ resultKind, resultToken = null, resultMessage = null },
now = Date.now(),
) => {
await pool.query(
`UPDATE h5_wechat_oauth_states
SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ?
WHERE state = ?`,
[resultKind, resultToken, resultMessage, now + STATE_TTL_MS, state],
);
};
const buildAuthorizeRedirect = async (req, { bindUserId = null } = {}) => {
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
const intent = normalizeIntent(req.query?.intent);
const authMode = normalizeAuthMode(req.query?.auth_mode);
if (authMode === 'open' && !config.scanEnabled) {
throw new Error('微信扫码登录未配置');
}
const state = await createState({
returnTo,
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
intent,
bindUserId,
authMode,
});
return buildAuthorizeUrl(config, { state, authMode });
};
const startScanLogin = async (req) => {
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
const utmSource = typeof req.query?.utm_source === 'string' ? req.query.utm_source : null;
const utmMedium = typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null;
const utmCampaign = typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null;
const common = {
returnTo,
utmSource,
utmMedium,
utmCampaign,
intent: 'login',
};
if (config.scanEnabled) {
const state = await createState({ ...common, authMode: 'open' });
return {
mode: 'open',
state,
openAppId: config.openAppId,
redirectUri: config.callbackUrl,
expiresInMs: STATE_TTL_MS,
};
}
const state = await createState({ ...common, authMode: 'scan' });
return {
mode: 'mp',
state,
qrUrl: buildAuthorizeUrl(config, { state, authMode: 'scan' }),
expiresInMs: STATE_TTL_MS,
};
};
const pollScanLogin = async (state, now = Date.now()) => {
const row = await loadState(state, now);
if (!row) return { status: 'expired' };
if (row.status !== 'done') return { status: 'pending' };
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
if (row.result_kind === 'error') {
return { status: 'error', message: row.result_message || '微信登录失败' };
}
if (row.result_kind === 'binding_gate') {
return { status: 'binding_gate', pendingToken: row.result_token };
}
if (row.result_kind === 'login') {
return { status: 'complete', token: row.result_token };
}
return { status: 'error', message: '微信登录结果无效' };
};
const handleCallback = async ({ code, state, ip = 'unknown' }) => {
if (!code) {
throw new Error('缺少微信授权 code');
}
const stateRow = await loadState(state);
if (!stateRow) {
throw new Error('授权状态无效或已过期,请重新登录');
}
const authMode = stateRow.auth_mode || 'mp';
const tokenInfo = await exchangeCode(config, code, authMode);
let nickname = null;
let avatarUrl = null;
let unionid = tokenInfo.unionid;
if ((authMode === 'mp' || authMode === 'scan') && config.scope === 'snsapi_userinfo') {
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
nickname = profile.nickname;
avatarUrl = profile.avatarUrl;
unionid = profile.unionid ?? unionid;
}
if (authMode === 'open') {
try {
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
nickname = profile.nickname;
avatarUrl = profile.avatarUrl;
unionid = profile.unionid ?? unionid;
} catch {
// snsapi_login may not always return userinfo; proceed with openid only
}
}
if (!userAuth?.resolveWechatAuth) {
throw new Error('用户系统未就绪');
}
const result = await userAuth.resolveWechatAuth({
appId: tokenInfo.appId,
openid: tokenInfo.openid,
unionid,
nickname,
avatarUrl,
intent: stateRow.intent || 'login',
bindUserId: stateRow.bind_user_id,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
ip,
});
if (!result.ok) {
if (isPollScanAuthMode(authMode)) {
await markStateComplete(state, {
resultKind: 'error',
resultMessage: result.message || '微信登录失败',
});
return {
authMode,
action: 'poll_error',
message: result.message || '微信登录失败',
};
}
throw new Error(result.message || '微信登录失败');
}
if (result.action === 'binding_gate') {
if (isPollScanAuthMode(authMode)) {
await markStateComplete(state, {
resultKind: 'binding_gate',
resultToken: result.pendingToken,
});
return {
authMode,
action: 'binding_gate',
pendingToken: result.pendingToken,
wechatProfile: result.wechatProfile,
returnTo: result.returnTo,
};
}
return {
authMode,
action: 'binding_gate',
pendingToken: result.pendingToken,
wechatProfile: result.wechatProfile,
returnTo: result.returnTo,
utmSource: result.utmSource,
utmMedium: result.utmMedium,
utmCampaign: result.utmCampaign,
};
}
if (isPollScanAuthMode(authMode)) {
await markStateComplete(state, {
resultKind: 'login',
resultToken: result.token,
});
return {
authMode,
action: 'login',
token: result.token,
user: result.user,
isNewUser: result.isNewUser,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
};
}
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
return {
authMode,
action: 'login',
token: result.token,
user: result.user,
isNewUser: result.isNewUser,
bound: result.bound,
returnTo: stateRow.return_to || '/',
utmSource: stateRow.utm_source,
utmMedium: stateRow.utm_medium,
utmCampaign: stateRow.utm_campaign,
};
};
return {
enabled: true,
publicConfig(req) {
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
return {
enabled: true,
inWechat,
scanEnabled: true,
openScanEnabled: Boolean(config.scanEnabled),
appId: config.appId,
openAppId: config.scanEnabled ? config.openAppId : undefined,
oauthCallbackUrl: config.scanEnabled ? config.callbackUrl : undefined,
};
},
buildAuthorizeRedirect,
startScanLogin,
pollScanLogin,
handleCallback,
};
}