221 lines
6.6 KiB
TypeScript
221 lines
6.6 KiB
TypeScript
import type { Logger } from 'pino';
|
|
|
|
import type { AppConfig } from '../config.js';
|
|
import { badRequest, upstreamError } from '../errors.js';
|
|
|
|
export type WechatIdentityProfile = {
|
|
providerUid: string;
|
|
providerUnionId: string | null;
|
|
displayName: string | null;
|
|
avatarUrl: string | null;
|
|
metaJson: Record<string, unknown> | null;
|
|
};
|
|
|
|
export type WechatAuthService = {
|
|
buildAuthorizationUrl(params: {
|
|
callbackUrl: string;
|
|
state: string;
|
|
userAgent?: string | null;
|
|
}): string;
|
|
resolveCallbackProfile(params: {
|
|
code?: string | null;
|
|
mockCode?: string | null;
|
|
}): Promise<WechatIdentityProfile>;
|
|
};
|
|
|
|
type WechatAuthorizationScene = 'desktop' | 'wechat_in_app';
|
|
|
|
const WECHAT_IN_APP_AUTHORIZE_ENDPOINT =
|
|
'https://open.weixin.qq.com/connect/oauth2/authorize';
|
|
|
|
function isWechatBrowser(userAgent?: string | null) {
|
|
return /MicroMessenger/iu.test(userAgent ?? '');
|
|
}
|
|
|
|
function isMobileBrowser(userAgent?: string | null) {
|
|
return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? '');
|
|
}
|
|
|
|
function resolveWechatAuthorizationScene(
|
|
userAgent?: string | null,
|
|
): WechatAuthorizationScene {
|
|
if (isWechatBrowser(userAgent)) {
|
|
return 'wechat_in_app';
|
|
}
|
|
|
|
if (isMobileBrowser(userAgent)) {
|
|
throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录');
|
|
}
|
|
|
|
return 'desktop';
|
|
}
|
|
|
|
class MockWechatAuthService implements WechatAuthService {
|
|
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
|
|
|
buildAuthorizationUrl(params: {
|
|
callbackUrl: string;
|
|
state: string;
|
|
userAgent?: string | null;
|
|
}) {
|
|
const callbackUrl = new URL(params.callbackUrl);
|
|
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
|
callbackUrl.searchParams.set('state', params.state);
|
|
return callbackUrl.toString();
|
|
}
|
|
|
|
async resolveCallbackProfile(params: {
|
|
mockCode?: string | null;
|
|
}) {
|
|
const mockCode = params.mockCode?.trim() || this.config.mockUserId;
|
|
return {
|
|
providerUid: mockCode,
|
|
providerUnionId: this.config.mockUnionId || null,
|
|
displayName: this.config.mockDisplayName || '微信旅人',
|
|
avatarUrl: this.config.mockAvatarUrl || null,
|
|
metaJson: {
|
|
mockCode,
|
|
},
|
|
} satisfies WechatIdentityProfile;
|
|
}
|
|
}
|
|
|
|
class RealWechatAuthService implements WechatAuthService {
|
|
constructor(
|
|
private readonly config: AppConfig['wechatAuth'],
|
|
private readonly logger: Logger,
|
|
) {
|
|
if (!config.appId || !config.appSecret) {
|
|
throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置');
|
|
}
|
|
}
|
|
|
|
buildAuthorizationUrl(params: {
|
|
callbackUrl: string;
|
|
state: string;
|
|
userAgent?: string | null;
|
|
}) {
|
|
const scene = resolveWechatAuthorizationScene(params.userAgent);
|
|
const url = new URL(
|
|
scene === 'wechat_in_app'
|
|
? WECHAT_IN_APP_AUTHORIZE_ENDPOINT
|
|
: this.config.authorizeEndpoint,
|
|
);
|
|
url.searchParams.set('appid', this.config.appId);
|
|
url.searchParams.set('redirect_uri', params.callbackUrl);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set(
|
|
'scope',
|
|
scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login',
|
|
);
|
|
url.searchParams.set('state', params.state);
|
|
return `${url.toString()}#wechat_redirect`;
|
|
}
|
|
|
|
async resolveCallbackProfile(params: {
|
|
code?: string | null;
|
|
}) {
|
|
const code = params.code?.trim();
|
|
if (!code) {
|
|
throw badRequest('缺少微信授权 code');
|
|
}
|
|
|
|
try {
|
|
const accessTokenUrl = new URL(this.config.accessTokenEndpoint);
|
|
accessTokenUrl.searchParams.set('appid', this.config.appId);
|
|
accessTokenUrl.searchParams.set('secret', this.config.appSecret);
|
|
accessTokenUrl.searchParams.set('code', code);
|
|
accessTokenUrl.searchParams.set('grant_type', 'authorization_code');
|
|
|
|
const accessTokenResponse = await fetch(accessTokenUrl.toString());
|
|
const accessTokenPayload =
|
|
(await accessTokenResponse.json()) as Record<string, unknown>;
|
|
|
|
if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') {
|
|
throw new Error(
|
|
typeof accessTokenPayload.errmsg === 'string'
|
|
? accessTokenPayload.errmsg
|
|
: 'failed to exchange code',
|
|
);
|
|
}
|
|
|
|
const accessToken =
|
|
typeof accessTokenPayload.access_token === 'string'
|
|
? accessTokenPayload.access_token
|
|
: '';
|
|
const openId = accessTokenPayload.openid;
|
|
const fallbackUnionId =
|
|
typeof accessTokenPayload.unionid === 'string'
|
|
? accessTokenPayload.unionid
|
|
: null;
|
|
|
|
if (!accessToken) {
|
|
throw new Error('missing access_token');
|
|
}
|
|
|
|
const userInfoUrl = new URL(this.config.userInfoEndpoint);
|
|
userInfoUrl.searchParams.set('access_token', accessToken);
|
|
userInfoUrl.searchParams.set('openid', openId);
|
|
userInfoUrl.searchParams.set('lang', 'zh_CN');
|
|
|
|
const userInfoResponse = await fetch(userInfoUrl.toString());
|
|
const userInfoPayload =
|
|
(await userInfoResponse.json()) as Record<string, unknown>;
|
|
|
|
if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') {
|
|
throw new Error(
|
|
typeof userInfoPayload.errmsg === 'string'
|
|
? userInfoPayload.errmsg
|
|
: 'failed to fetch user info',
|
|
);
|
|
}
|
|
|
|
return {
|
|
providerUid: userInfoPayload.openid,
|
|
providerUnionId:
|
|
typeof userInfoPayload.unionid === 'string'
|
|
? userInfoPayload.unionid
|
|
: fallbackUnionId,
|
|
displayName:
|
|
typeof userInfoPayload.nickname === 'string'
|
|
? userInfoPayload.nickname
|
|
: null,
|
|
avatarUrl:
|
|
typeof userInfoPayload.headimgurl === 'string'
|
|
? userInfoPayload.headimgurl
|
|
: null,
|
|
metaJson: userInfoPayload,
|
|
} satisfies WechatIdentityProfile;
|
|
} catch (error) {
|
|
this.logger.error({ err: error }, 'wechat auth callback failed');
|
|
throw upstreamError(
|
|
error instanceof Error
|
|
? `微信登录失败:${error.message}`
|
|
: '微信登录失败',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createWechatAuthService(
|
|
config: AppConfig,
|
|
logger: Logger,
|
|
): WechatAuthService {
|
|
if (!config.wechatAuth.enabled) {
|
|
return {
|
|
buildAuthorizationUrl() {
|
|
throw badRequest('微信登录暂未启用');
|
|
},
|
|
async resolveCallbackProfile() {
|
|
throw badRequest('微信登录暂未启用');
|
|
},
|
|
};
|
|
}
|
|
|
|
if (config.wechatAuth.provider === 'mock') {
|
|
return new MockWechatAuthService(config.wechatAuth);
|
|
}
|
|
|
|
return new RealWechatAuthService(config.wechatAuth, logger);
|
|
}
|