Files
memind/wechat-oauth.mjs
T
John 2e14873f2d Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:04:43 -07:00

232 lines
7.1 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_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;
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 scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo';
const callbackUrl = resolveCallbackUrl(env);
const configured = Boolean(appId && appSecret && callbackUrl);
return {
enabled: enabledFlag && configured,
appId,
appSecret,
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 buildAuthorizeUrl(config, { state }) {
const params = new URLSearchParams({
appid: config.appId,
redirect_uri: config.callbackUrl,
response_type: 'code',
scope: config.scope,
state,
});
return `${OAUTH_AUTHORIZE_URL}?${params.toString()}#wechat_redirect`;
}
async function exchangeCode(config, code) {
const params = new URLSearchParams({
appid: config.appId,
secret: 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,
};
}
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 };
},
};
}
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,
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, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[state, returnTo, utmSource, utmMedium, utmCampaign, now + STATE_TTL_MS, now],
);
}
return state;
};
const consumeState = async (state, now = Date.now()) => {
if (!state) return null;
if (!pool) return null;
const [rows] = await pool.query(
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, expires_at
FROM h5_wechat_oauth_states
WHERE state = ?
LIMIT 1`,
[state],
);
const row = rows[0];
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
if (!row || Number(row.expires_at) <= now) return null;
return {
returnTo: row.return_to || '/',
utmSource: row.utm_source,
utmMedium: row.utm_medium,
utmCampaign: row.utm_campaign,
};
};
const buildAuthorizeRedirect = async (req) => {
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
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,
});
return buildAuthorizeUrl(config, { state });
};
const handleCallback = async ({ code, state, ip = 'unknown' }) => {
if (!code) {
throw new Error('缺少微信授权 code');
}
const stateRow = await consumeState(state);
if (!stateRow) {
throw new Error('授权状态无效或已过期,请重新登录');
}
const tokenInfo = await exchangeCode(config, code);
let nickname = null;
let avatarUrl = null;
let unionid = tokenInfo.unionid;
if (config.scope === 'snsapi_userinfo') {
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
nickname = profile.nickname;
avatarUrl = profile.avatarUrl;
unionid = profile.unionid ?? unionid;
}
if (!userAuth?.loginByWechat) {
throw new Error('用户系统未就绪');
}
const result = await userAuth.loginByWechat({
appId: config.appId,
openid: tokenInfo.openid,
unionid,
nickname,
avatarUrl,
ip,
utmSource: stateRow.utmSource,
utmMedium: stateRow.utmMedium,
utmCampaign: stateRow.utmCampaign,
});
if (!result.ok) {
throw new Error(result.message || '微信登录失败');
}
return {
token: result.token,
user: result.user,
isNewUser: result.isNewUser,
returnTo: stateRow.returnTo,
utmSource: stateRow.utmSource,
utmMedium: stateRow.utmMedium,
utmCampaign: stateRow.utmCampaign,
};
};
return {
enabled: true,
publicConfig(req) {
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
return { enabled: true, inWechat };
},
buildAuthorizeRedirect,
handleCallback,
};
}