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

@@ -3,9 +3,10 @@ import {
bindPhoneRoute,
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
qqLoginRoute,
sendSmsCodeRoute,
verifySmsCodeRoute,
wechatWebLoginRoute,
wechatMiniappLoginRoute,
} from './routes.js';
@@ -15,7 +16,7 @@ export const authRoutes: RouteDefinition[] = [
['GET', '/api/auth/me', meRoute],
['POST', '/api/auth/logout', logoutRoute],
['POST', '/api/auth/phone/bind', bindPhoneRoute],
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/wechat', wechatWebLoginRoute],
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/qq', qqLoginRoute],
];

View File

@@ -9,6 +9,7 @@ import {
loadTenantAuthProviderConfig,
optionalPublicString,
providerEndpoint,
providerEndpointForKeys,
requirePublicString,
requireSecretString,
} from '../../core/tenant-provider-config.js';
@@ -649,6 +650,134 @@ function oauthProviderAliases(value: string) {
return [provider];
}
function stringField(source: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function intField(source: Record<string, unknown>, keys: string[]) {
const value = stringField(source, keys) || keys.map(key => source[key]).find(item => typeof item === 'number');
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function sanitizeOAuthProfilePayload(value: unknown): unknown {
if (Array.isArray(value)) return value.map(item => sanitizeOAuthProfilePayload(item));
if (!value || typeof value !== 'object') return value;
const sanitized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
if (
normalized.includes('accesstoken') ||
normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') ||
normalized.includes('secret') ||
normalized.includes('clientsecret')
) {
continue;
}
sanitized[key] = sanitizeOAuthProfilePayload(child);
}
return sanitized;
}
async function parseOAuthProviderResponse(response: Response) {
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) return {};
const tryJson = (candidate: string) => {
try {
return jsonObject(JSON.parse(candidate));
} catch {
return null;
}
};
const directJson = tryJson(trimmed);
if (directJson) return directJson;
const jsonStart = trimmed.indexOf('{');
const jsonEnd = trimmed.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
const jsonpJson = tryJson(trimmed.slice(jsonStart, jsonEnd + 1));
if (jsonpJson) return jsonpJson;
}
const params = new URLSearchParams(trimmed);
const parsed = Object.fromEntries(params.entries());
return jsonObject(parsed);
}
async function callWechatWebAccessToken(input: {
endpoint: string;
appId: string;
appSecret: string;
code: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('appid', input.appId);
url.searchParams.set('secret', input.appSecret);
url.searchParams.set('code', input.code);
url.searchParams.set('grant_type', 'authorization_code');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const errcode = Number(raw.errcode || 0);
if (!response.ok || errcode) {
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
throw new HttpError(401, `WeChat web code exchange failed${suffix}`, 'WECHAT_WEB_CODE_EXCHANGE_FAILED');
}
const accessToken = stringField(raw, ['access_token', 'accessToken']);
const openId = stringField(raw, ['openid', 'openId']);
const unionId = stringField(raw, ['unionid', 'unionId']);
const scope = stringField(raw, ['scope']);
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
if (!accessToken || !openId) {
throw new HttpError(401, 'WeChat web token response is missing access_token or openid', 'WECHAT_WEB_CODE_EXCHANGE_INVALID');
}
return { accessToken, openId, unionId, scope, expiresIn };
}
async function callWechatWebUserInfo(input: {
endpoint: string;
accessToken: string;
openId: string;
lang: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('openid', input.openId);
url.searchParams.set('lang', input.lang);
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const errcode = Number(raw.errcode || 0);
if (!response.ok || errcode) {
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
throw new HttpError(401, `WeChat web userinfo failed${suffix}`, 'WECHAT_WEB_USERINFO_FAILED');
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
const unionId = stringField(raw, ['unionid', 'unionId']);
return {
nickname,
avatarUrl,
unionId,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
async function callWechatCode2Session(input: {
endpoint: string;
appId: string;
@@ -761,13 +890,302 @@ export async function wechatMiniappLoginRoute(ctx: RequestContext) {
return result;
}
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
const normalized = normalizeOAuthProvider(provider);
const configured = await loadTenantAuthProviderConfig(await tenantIdFrom(ctx), oauthProviderAliases(normalized));
throw new HttpError(
configured ? 501 : 503,
`${provider} OAuth adapter is not implemented yet. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
configured ? 'PROVIDER_NOT_IMPLEMENTED' : 'PROVIDER_NOT_CONFIGURED',
export async function wechatWebLoginRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = requiredString(body, 'code');
const clientProfile = jsonObject(body.profile);
const lang = optionalString(body, 'lang') || 'zh_CN';
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-web'));
if (!providerConfig) {
throw new HttpError(503, 'WeChat web auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
}
const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
const tokenEndpoint = providerEndpointForKeys(
providerConfig,
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
'https://api.weixin.qq.com/sns/oauth2/access_token',
['weixin.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const userInfoEndpoint = providerEndpointForKeys(
providerConfig,
['userInfoEndpoint', 'userinfoEndpoint'],
'https://api.weixin.qq.com/sns/userinfo',
['weixin.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const token = await callWechatWebAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code });
const userInfo = await callWechatWebUserInfo({
endpoint: userInfoEndpoint,
accessToken: token.accessToken,
openId: token.openId,
lang,
});
const provider = 'wechat_web';
const unionId = userInfo.unionId || token.unionId || null;
const providerSubject = `${appId}:${token.openId}`;
const profile = {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {
const { user, isNewUser } = await upsertOAuthUser(client, {
tenantId,
provider,
providerSubject,
openId: token.openId,
unionId,
profile,
secretPayload: {
tokenSeenAt: new Date().toISOString(),
scope: token.scope || null,
expiresIn: token.expiresIn,
},
});
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider,
ipAddress,
userAgent,
metadata: {
appId,
openId: token.openId,
unionId,
isNewUser,
},
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider,
identifier: token.openId,
result: 'success',
ipAddress,
userAgent,
metadata: {
appId,
unionId,
isNewUser,
},
});
return {
provider,
user,
isNewUser,
session,
identity: {
provider,
openId: token.openId,
unionId,
},
};
});
}
async function callQqAccessToken(input: {
endpoint: string;
appId: string;
appSecret: string;
code: string;
redirectUri: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('grant_type', 'authorization_code');
url.searchParams.set('client_id', input.appId);
url.searchParams.set('client_secret', input.appSecret);
url.searchParams.set('code', input.code);
url.searchParams.set('redirect_uri', input.redirectUri);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const error = stringField(raw, ['error']);
if (!response.ok || error) {
throw new HttpError(401, 'QQ code exchange failed', 'QQ_CODE_EXCHANGE_FAILED');
}
const accessToken = stringField(raw, ['access_token', 'accessToken']);
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
if (!accessToken) {
throw new HttpError(401, 'QQ token response is missing access_token', 'QQ_CODE_EXCHANGE_INVALID');
}
return { accessToken, expiresIn };
}
async function callQqOpenId(input: { endpoint: string; appId: string; accessToken: string }) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const error = stringField(raw, ['error']);
if (!response.ok || error) {
throw new HttpError(401, 'QQ openid exchange failed', 'QQ_OPENID_EXCHANGE_FAILED');
}
const openId = stringField(raw, ['openid', 'openId']);
const clientId = stringField(raw, ['client_id', 'clientId']);
if (!openId) {
throw new HttpError(401, 'QQ openid response is missing openid', 'QQ_OPENID_EXCHANGE_INVALID');
}
if (clientId && clientId !== input.appId) {
throw new HttpError(401, 'QQ openid response client_id does not match appId', 'QQ_OPENID_CLIENT_MISMATCH');
}
return { openId, clientId };
}
async function callQqUserInfo(input: {
endpoint: string;
appId: string;
accessToken: string;
openId: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('oauth_consumer_key', input.appId);
url.searchParams.set('openid', input.openId);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const ret = Number(raw.ret || 0);
if (!response.ok || ret !== 0) {
throw new HttpError(401, 'QQ userinfo failed', 'QQ_USERINFO_FAILED');
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
return {
nickname,
avatarUrl,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
export async function qqLoginRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = requiredString(body, 'code');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('qq'));
if (!providerConfig) {
throw new HttpError(503, 'QQ auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
}
const appId = requirePublicString(providerConfig, ['appId', 'clientId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
const redirectUri = optionalPublicString(providerConfig, ['redirectUri', 'callbackUrl']) || optionalString(body, 'redirectUri');
if (!redirectUri) {
throw new HttpError(400, 'QQ OAuth redirectUri is required for code exchange', 'OAUTH_REDIRECT_URI_REQUIRED');
}
const tokenEndpoint = providerEndpointForKeys(
providerConfig,
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
'https://graph.qq.com/oauth2.0/token',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const openIdEndpoint = providerEndpointForKeys(
providerConfig,
['openIdEndpoint', 'openidEndpoint'],
'https://graph.qq.com/oauth2.0/me',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const userInfoEndpoint = providerEndpointForKeys(
providerConfig,
['userInfoEndpoint', 'userinfoEndpoint'],
'https://graph.qq.com/user/get_user_info',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const token = await callQqAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code, redirectUri });
const openId = await callQqOpenId({ endpoint: openIdEndpoint, appId, accessToken: token.accessToken });
const userInfo = await callQqUserInfo({
endpoint: userInfoEndpoint,
appId,
accessToken: token.accessToken,
openId: openId.openId,
});
const provider = 'qq';
const providerSubject = `${appId}:${openId.openId}`;
const clientProfile = jsonObject(body.profile);
const profile = {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {
const { user, isNewUser } = await upsertOAuthUser(client, {
tenantId,
provider,
providerSubject,
openId: openId.openId,
profile,
secretPayload: {
tokenSeenAt: new Date().toISOString(),
expiresIn: token.expiresIn,
},
});
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider,
ipAddress,
userAgent,
metadata: {
appId,
openId: openId.openId,
isNewUser,
},
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider,
identifier: openId.openId,
result: 'success',
ipAddress,
userAgent,
metadata: {
appId,
isNewUser,
},
});
return {
provider,
user,
isNewUser,
session,
identity: {
provider,
openId: openId.openId,
},
};
});
}