feat: add web oauth login adapters

This commit is contained in:
Codex
2026-06-29 10:20:20 +08:00
parent 64d58d895e
commit d89f77e9af
15 changed files with 766 additions and 41 deletions

View File

@@ -79,6 +79,7 @@ let serverLogs = '';
let legacyDisabledServer = null;
let legacyDisabledServerLogs = '';
let fakeWechatServer = null;
let fakeQqServer = null;
let fakeWechatPayServer = null;
function buildUrl(path, query = {}) {
@@ -268,22 +269,58 @@ async function startFakeWechatServer() {
query: Object.fromEntries(url.searchParams.entries()),
});
if (url.pathname !== '/sns/jscode2session') {
if (!['/sns/jscode2session', '/sns/oauth2/access_token', '/sns/userinfo'].includes(url.pathname)) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' }));
return;
}
if (
url.searchParams.get('appid') !== 'wx-smoke-appid' ||
url.searchParams.get('secret') !== 'wechat-app-secret-smoke' ||
url.searchParams.get('grant_type') !== 'authorization_code'
) {
if (url.pathname === '/sns/userinfo') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!accessToken.startsWith('wechat-web-token-') || !openid.startsWith('wechat-web-openid-')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40003, errmsg: 'invalid openid' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
openid,
nickname: '微信网页烟测学生',
headimgurl: 'https://example.test/wechat-web-avatar.png',
unionid: 'unionid-smoke-user',
privilege: [],
}),
);
return;
}
const appId = url.searchParams.get('appid');
const secret = url.searchParams.get('secret');
const grantType = url.searchParams.get('grant_type');
if (appId !== 'wx-smoke-appid' || secret !== 'wechat-app-secret-smoke' || grantType !== 'authorization_code') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' }));
return;
}
if (url.pathname === '/sns/oauth2/access_token') {
const webCode = url.searchParams.get('code') || 'unknown';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
access_token: `wechat-web-token-${webCode}`,
expires_in: 7200,
refresh_token: `wechat-web-refresh-${webCode}`,
openid: `wechat-web-openid-${webCode}`,
scope: 'snsapi_login',
unionid: 'unionid-smoke-user',
}),
);
return;
}
const jsCode = url.searchParams.get('js_code') || 'unknown';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
@@ -302,6 +339,91 @@ async function startFakeWechatServer() {
return {
endpoint: `${baseUrl}/sns/jscode2session`,
webTokenEndpoint: `${baseUrl}/sns/oauth2/access_token`,
webUserInfoEndpoint: `${baseUrl}/sns/userinfo`,
requests,
};
}
async function startFakeQqServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const tokenToOpenId = new Map();
fakeQqServer = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
requests.push({
method: req.method,
pathname: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
});
if (url.pathname === '/oauth2.0/token') {
if (
url.searchParams.get('client_id') !== 'qq-smoke-appid' ||
url.searchParams.get('client_secret') !== 'qq-client-secret-smoke' ||
url.searchParams.get('grant_type') !== 'authorization_code' ||
url.searchParams.get('redirect_uri') !== 'https://h5.example.test/auth/qq/callback'
) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 100010, error_description: 'invalid client' }));
return;
}
const code = url.searchParams.get('code') || 'unknown';
const accessToken = `qq-token-${code}`;
tokenToOpenId.set(accessToken, `qq-openid-${code}`);
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`access_token=${encodeURIComponent(accessToken)}&expires_in=7776000&refresh_token=qq-refresh-${encodeURIComponent(code)}`);
return;
}
if (url.pathname === '/oauth2.0/me') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = tokenToOpenId.get(accessToken);
if (!openid) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end('callback( {"error":100016,"error_description":"access token check failed"} );');
return;
}
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(`callback( {"client_id":"qq-smoke-appid","openid":"${openid}"} );`);
return;
}
if (url.pathname === '/user/get_user_info') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!tokenToOpenId.has(accessToken) || tokenToOpenId.get(accessToken) !== openid) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 100001, msg: 'invalid token' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ret: 0,
msg: '',
nickname: 'QQ烟测学生',
figureurl_qq_2: 'https://example.test/qq-avatar.png',
}),
);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 404, msg: 'not found' }));
});
await new Promise((resolve, reject) => {
fakeQqServer.once('error', reject);
fakeQqServer.listen(port, '127.0.0.1', resolve);
});
return {
tokenEndpoint: `${baseUrl}/oauth2.0/token`,
openIdEndpoint: `${baseUrl}/oauth2.0/me`,
userInfoEndpoint: `${baseUrl}/user/get_user_info`,
requests,
};
}
@@ -664,6 +786,7 @@ async function testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
const newPhone = `13921${phoneSuffix}`;
const wrongPurposePhone = `13922${phoneSuffix}`;
const login = await loginBySms(oldPhone);
const authHeaders = { authorization: `Bearer ${login.session.token}` };
@@ -697,12 +820,12 @@ async function testPhoneBinding() {
});
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
const wrongPurposeCode = await sendMockSmsCode('13800000022', 'login');
const wrongPurposeCode = await sendMockSmsCode(wrongPurposePhone, 'login');
const wrongPurpose = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000022', code: wrongPurposeCode, purpose: 'login' },
body: { phone: wrongPurposePhone, code: wrongPurposeCode, purpose: 'login' },
expectStatus: 400,
});
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
@@ -824,6 +947,10 @@ function stopServer() {
fakeWechatServer.close();
fakeWechatServer = null;
}
if (fakeQqServer) {
fakeQqServer.close();
fakeQqServer = null;
}
if (fakeWechatPayServer) {
fakeWechatPayServer.close();
fakeWechatPayServer = null;
@@ -3921,6 +4048,7 @@ async function testPublicQuestionBankAdoption() {
async function testTenantAdminOps() {
const fakeWechat = await startFakeWechatServer();
const fakeQq = await startFakeQqServer();
const denied = await request('/api/tenant-admin/branding', {
method: 'PUT',
body: { brandName: '学生不能改品牌' },
@@ -4046,6 +4174,84 @@ async function testTenantAdminOps() {
});
assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me');
const wechatWebProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'wechat-web',
displayName: '微信网页登录',
status: 'testing',
configPublic: {
appId: 'wx-smoke-appid',
endpoint: fakeWechat.webTokenEndpoint,
userInfoEndpoint: fakeWechat.webUserInfoEndpoint,
},
secret: {
secretValue: 'wechat-app-secret-smoke',
},
},
});
assert.equal(wechatWebProvider.item?.provider, 'wechat-web', 'tenant admin should upsert WeChat web provider');
assert.equal(wechatWebProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-web', 'WeChat web provider should expose only secretRef');
const wechatWebLogin = await request('/api/auth/oauth/wechat', {
userId: false,
method: 'POST',
body: {
code: 'web-code-001',
lang: 'zh_CN',
},
});
assert.equal(wechatWebLogin.provider, 'wechat_web', 'wechat web login should return canonical provider');
assert.equal(wechatWebLogin.user?.id, miniappLogin.user.id, 'wechat web login should merge with miniapp account by unionId');
assert.ok(wechatWebLogin.session?.token?.startsWith('tk_'), 'wechat web login should issue API session token');
assert.equal(wechatWebLogin.identity?.openId, 'wechat-web-openid-web-code-001', 'wechat web login should expose openId');
assert.equal(wechatWebLogin.identity?.unionId, 'unionid-smoke-user', 'wechat web login should expose unionId');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/oauth2/access_token'), 'wechat web login should exchange code server-side');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret');
const qqProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'qq-oauth',
displayName: 'QQ网页登录',
status: 'testing',
configPublic: {
appId: 'qq-smoke-appid',
redirectUri: 'https://h5.example.test/auth/qq/callback',
endpoint: fakeQq.tokenEndpoint,
openIdEndpoint: fakeQq.openIdEndpoint,
userInfoEndpoint: fakeQq.userInfoEndpoint,
},
secret: {
secretValue: 'qq-client-secret-smoke',
},
},
});
assert.equal(qqProvider.item?.provider, 'qq-oauth', 'tenant admin should upsert QQ OAuth provider');
assert.equal(qqProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:qq-oauth', 'QQ provider should expose only secretRef');
const qqLogin = await request('/api/auth/oauth/qq', {
userId: false,
method: 'POST',
body: {
code: 'qq-code-001',
redirectUri: 'https://h5.example.test/auth/qq/callback',
},
});
assert.equal(qqLogin.provider, 'qq', 'qq login should return canonical provider');
assert.ok(qqLogin.user?.id, 'qq login should create or resolve user');
assert.ok(qqLogin.session?.token?.startsWith('tk_'), 'qq login should issue API session token');
assert.equal(qqLogin.identity?.openId, 'qq-openid-qq-code-001', 'qq login should expose openId');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/token'), 'qq login should exchange code server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/me'), 'qq login should fetch openid server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side');
assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token');
assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret');
const paymentAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',