0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core): add wechat-web connector (#394)

* feat(core): add WeChat connector as well as corresponding UTs

* feat(core): move TODO from comment block
This commit is contained in:
Darcy Ye 2022-03-18 17:39:20 +08:00 committed by GitHub
parent b196d21ca2
commit fa757c3d12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 317 additions and 23 deletions

View file

@ -62,7 +62,7 @@ describe('facebook connector', () => {
token_type: 'token_type',
});
const accessToken = await getAccessToken(code, dummyRedirectUri);
const { accessToken } = await getAccessToken(code, dummyRedirectUri);
expect(accessToken).toEqual('access_token');
});
@ -96,7 +96,7 @@ describe('facebook connector', () => {
picture: { data: { url: avatar } },
});
const socialUserInfo = await getUserInfo(code);
const socialUserInfo = await getUserInfo({ accessToken: code });
expect(socialUserInfo).toMatchObject({
id: '1234567890',
avatar,
@ -107,14 +107,14 @@ describe('facebook connector', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').query({ fields }).reply(400);
await expect(getUserInfo(code)).rejects.toMatchError(
await expect(getUserInfo({ accessToken: code })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
await expect(getUserInfo(code)).rejects.toThrow();
await expect(getUserInfo({ accessToken: code })).rejects.toThrow();
});
});
});

View file

@ -100,10 +100,10 @@ export const getAccessToken: GetAccessToken = async (code, redirectUri) => {
assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return accessToken;
return { accessToken };
};
export const getUserInfo: GetUserInfo = async (accessToken: string) => {
export const getUserInfo: GetUserInfo = async (accessTokenObject) => {
type UserInfoResponse = {
id: string;
email?: string;
@ -111,6 +111,8 @@ export const getUserInfo: GetUserInfo = async (accessToken: string) => {
picture?: { data: { url: string } };
};
const { accessToken } = accessTokenObject;
try {
const { id, email, name, picture } = await got
.get(userInfoEndpoint, {

View file

@ -33,7 +33,7 @@ describe('getAccessToken', () => {
scope: 'scope',
token_type: 'token_type',
});
const accessToken = await getAccessToken('code', 'dummyRedirectUri');
const { accessToken } = await getAccessToken('code', 'dummyRedirectUri');
expect(accessToken).toEqual('access_token');
});
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
@ -66,7 +66,7 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
});
const socialUserInfo = await getUserInfo('code');
const socialUserInfo = await getUserInfo({ accessToken: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -76,12 +76,12 @@ describe('getUserInfo', () => {
});
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').reply(401);
await expect(getUserInfo('code')).rejects.toMatchError(
await expect(getUserInfo({ accessToken: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
await expect(getUserInfo('code')).rejects.toThrow();
await expect(getUserInfo({ accessToken: 'code' })).rejects.toThrow();
});
});

View file

@ -90,10 +90,10 @@ export const getAccessToken: GetAccessToken = async (code) => {
assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return accessToken;
return { accessToken };
};
export const getUserInfo: GetUserInfo = async (accessToken: string) => {
export const getUserInfo: GetUserInfo = async (accessTokenObject) => {
type UserInfoResponse = {
id: number;
avatar_url?: string;
@ -101,6 +101,8 @@ export const getUserInfo: GetUserInfo = async (accessToken: string) => {
name?: string;
};
const { accessToken } = accessTokenObject;
try {
const {
id,

View file

@ -48,7 +48,7 @@ describe('google connector', () => {
scope: 'scope',
token_type: 'token_type',
});
const accessToken = await getAccessToken('code', 'dummyRedirectUri');
const { accessToken } = await getAccessToken('code', 'dummyRedirectUri');
expect(accessToken).toEqual('access_token');
});
@ -72,7 +72,7 @@ describe('google connector', () => {
email_verified: true,
locale: 'en',
});
const socialUserInfo = await getUserInfo('code');
const socialUserInfo = await getUserInfo({ accessToken: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1234567890',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -83,14 +83,14 @@ describe('google connector', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).post('').reply(401);
await expect(getUserInfo('code')).rejects.toMatchError(
await expect(getUserInfo({ accessToken: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).post('').reply(500);
await expect(getUserInfo('code')).rejects.toThrow();
await expect(getUserInfo({ accessToken: 'code' })).rejects.toThrow();
});
});
});

View file

@ -100,10 +100,10 @@ export const getAccessToken: GetAccessToken = async (code, redirectUri) => {
assertThat(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return accessToken;
return { accessToken };
};
export const getUserInfo: GetUserInfo = async (accessToken: string) => {
export const getUserInfo: GetUserInfo = async (accessTokenObject) => {
type UserInfoResponse = {
sub: string;
name?: string;
@ -115,6 +115,8 @@ export const getUserInfo: GetUserInfo = async (accessToken: string) => {
locale?: string;
};
const { accessToken } = accessTokenObject;
try {
const {
sub: id,

View file

@ -40,6 +40,12 @@ const googleConnector = {
config: {},
createdAt: 1_646_382_233_000,
};
const wechatConnector = {
id: 'wechat',
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
};
const connectors = [
aliyunDmConnector,
@ -47,6 +53,7 @@ const connectors = [
facebookConnector,
githubConnector,
googleConnector,
wechatConnector,
];
const connectorMap = new Map(connectors.map((connector) => [connector.id, connector]));
@ -79,6 +86,7 @@ describe('getConnectorInstances', () => {
expect(connectorInstances[2]).toHaveProperty('connector', facebookConnector);
expect(connectorInstances[3]).toHaveProperty('connector', githubConnector);
expect(connectorInstances[4]).toHaveProperty('connector', googleConnector);
expect(connectorInstances[5]).toHaveProperty('connector', wechatConnector);
});
});

View file

@ -7,8 +7,9 @@ import * as Facebook from './facebook';
import * as GitHub from './github';
import * as Google from './google';
import { ConnectorInstance, ConnectorType, IConnector, SocialConnectorInstance } from './types';
import * as WeChat from './wechat';
const allConnectors: IConnector[] = [AliyunDM, AliyunSMS, Facebook, GitHub, Google];
const allConnectors: IConnector[] = [AliyunDM, AliyunSMS, Facebook, GitHub, Google, WeChat];
export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
return Promise.all(

View file

@ -87,9 +87,11 @@ export type ValidateConfig<T extends ArbitraryObject = ArbitraryObject> = (
export type GetAuthorizationUri = (redirectUri: string, state: string) => Promise<string>;
export type GetAccessToken = (code: string, redirectUri: string) => Promise<string>;
type AccessTokenObject = { accessToken: string } & Record<string, string>;
export type GetUserInfo = (accessToken: string) => Promise<SocialUserInfo>;
export type GetAccessToken = (code: string, redirectUri?: string) => Promise<AccessTokenObject>;
export type GetUserInfo = (accessTokenObject: AccessTokenObject) => Promise<SocialUserInfo>;
export const socialUserInfoGuard = z.object({
id: z.string(),

View file

@ -0,0 +1,2 @@
### WeChat Social Connector README
placeholder

View file

@ -0,0 +1,4 @@
export const authorizationEndpoint = 'https://open.weixin.qq.com/connect/qrconnect';
export const accessTokenEndpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token';
export const userInfoEndpoint = 'https://api.weixin.qq.com/sns/userinfo';
export const scope = 'snsapi_login';

View file

@ -0,0 +1,133 @@
import nock from 'nock';
import { getAccessToken, getAuthorizationUri, validateConfig, getUserInfo } from '.';
import { ConnectorError, ConnectorErrorCodes } from '../types';
import { getConnectorConfig } from '../utilities';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
jest.mock('../utilities');
beforeAll(() => {
(getConnectorConfig as jest.MockedFunction<typeof getConnectorConfig>).mockResolvedValue({
appId: '<app-id>',
appSecret: '<app-secret>',
});
});
describe('getAuthorizationUri', () => {
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await getAuthorizationUri(
'http://localhost:3001/callback',
'some_state'
);
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?appid=%3Capp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&response_type=code&scope=snsapi_login&state=some_state`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
});
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
appid: '<app-id>',
secret: '<app-secret>',
code: 'code',
grant_type: 'authorization_code',
});
it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {
access_token: 'access_token',
openid: 'openid',
});
const { accessToken, openid } = await getAccessToken('code');
expect(accessToken).toEqual('access_token');
expect(openid).toEqual('openid');
});
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
nock(accessTokenEndpointUrl.origin)
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, {});
await expect(getAccessToken('code')).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
});
});
describe('validateConfig', () => {
it('should pass on valid config', async () => {
await expect(validateConfig({ appId: 'appId', appSecret: 'appSecret' })).resolves.not.toThrow();
});
it('should throw on empty config', async () => {
await expect(validateConfig({})).rejects.toThrowError();
});
it('should throw when missing appSecret', async () => {
await expect(validateConfig({ appId: 'appId' })).rejects.toThrowError();
});
});
describe('getUserInfo', () => {
afterEach(() => {
nock.cleanAll();
});
const userInfoEndpointUrl = new URL(userInfoEndpoint);
const parameters = new URLSearchParams({ access_token: 'accessToken', openid: 'openid' });
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, {
unionid: 'this_is_an_arbitrary_wechat_union_id',
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
const socialUserInfo = await getUserInfo({ accessToken: 'accessToken', openid: 'openid' });
expect(socialUserInfo).toMatchObject({
id: 'this_is_an_arbitrary_wechat_union_id',
avatar: 'https://github.com/images/error/octocat_happy.gif',
name: 'wechat bot',
});
});
it('throws SocialAccessTokenInvalid error if remote response code is 40001', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(40_001);
await expect(
getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
await expect(getUserInfo({ accessToken: 'accessToken', openid: 'openid' })).rejects.toThrow();
});
it('throws Error if request failed and errcode is not 40001', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_003, errmsg: 'invalid openid' });
await expect(
getUserInfo({ accessToken: 'accessToken', openid: 'openid' })
).rejects.toMatchError(new Error('invalid openid'));
});
it('throws SocialAccessTokenInvalid error if openid is missing', async () => {
await expect(getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
});

View file

@ -0,0 +1,138 @@
/**
* The Implementation of OpenID Connect of WeChat Web Open Platform.
* https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
*/
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import got, { RequestError as GotRequestError } from 'got';
import { stringify } from 'query-string';
import { z } from 'zod';
import assertThat from '@/utils/assert-that';
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
ConnectorType,
ConnectorError,
ConnectorErrorCodes,
} from '../types';
import { getConnectorConfig, getConnectorRequestTimeout } from '../utilities';
import { authorizationEndpoint, accessTokenEndpoint, userInfoEndpoint, scope } from './constant';
// eslint-disable-next-line unicorn/prefer-module
const pathToReadmeFile = path.join(__dirname, 'README.md');
const readmeContentFallback = 'Please check README.md file directory.';
export const metadata: ConnectorMetadata = {
id: 'wechat',
type: ConnectorType.Social,
name: {
en: 'Sign In with WeChat',
'zh-CN': '微信登录',
},
// TODO: add the real logo URL (LOG-1823)
logo: './logo.png',
description: {
en: 'Sign In with WeChat',
'zh-CN': '微信登录',
},
readme: existsSync(pathToReadmeFile)
? readFileSync(pathToReadmeFile, 'utf8')
: readmeContentFallback,
};
// As creating a WeChat Web/Mobile application needs a real App or Website record, the real test is temporarily not finished.
// TODO: test with our own wechat mobile/web application (LOG-1910), already tested with other verified wechat web application
const weChatConfigGuard = z.object({ appId: z.string(), appSecret: z.string() });
type WeChatConfig = z.infer<typeof weChatConfigGuard>;
export const validateConfig: ValidateConfig = async (config: unknown) => {
const result = weChatConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
}
};
export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
const { appId } = await getConnectorConfig<WeChatConfig>(metadata.id);
return `${authorizationEndpoint}?${stringify({
appid: appId,
redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret}
response_type: 'code',
scope,
state,
})}`;
};
export const getAccessToken: GetAccessToken = async (code) => {
type AccessTokenResponse = {
access_token: string;
openid: string;
expires_in: number; // In seconds
refresh_token: string;
scope: string;
};
const config = await getConnectorConfig<WeChatConfig>(metadata.id);
const { appId: appid, appSecret: secret } = config;
const { access_token: accessToken, openid } = await got
.get(accessTokenEndpoint, {
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
timeout: await getConnectorRequestTimeout(),
})
.json<AccessTokenResponse>();
assertThat(accessToken && openid, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken, openid };
};
export const getUserInfo: GetUserInfo = async (accessTokenObject) => {
type UserInfoResponse = {
unionid?: string;
headimgurl?: string;
nickname?: string;
errcode?: number;
errmsg?: string;
};
const { accessToken, openid } = accessTokenObject;
try {
const { unionid, headimgurl, nickname, errcode, errmsg } = await got
.get(userInfoEndpoint, {
searchParams: { access_token: accessToken, openid },
timeout: await getConnectorRequestTimeout(),
})
.json<UserInfoResponse>();
if (!openid || errcode || errmsg) {
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily to
// be the return value from getAccessToken per testing.
// In another word, 'openid' is required but the response of getUserInfo is consistent as long as
// access_token is valid.
if (errcode === 40_001) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new Error(errmsg);
}
return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) {
if (error instanceof GotRequestError && error.response?.statusCode === 40_001) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw error;
}
};

View file

@ -40,9 +40,9 @@ export const getUserInfoByAuthCode = async (
redirectUri: string
): Promise<SocialUserInfo> => {
const connector = await getConnector(connectorId);
const accessToken = await connector.getAccessToken(authCode, redirectUri);
const accessTokenObject = await connector.getAccessToken(authCode, redirectUri);
return connector.getUserInfo(accessToken);
return connector.getUserInfo(accessTokenObject);
};
export const getUserInfoFromInteractionResult = async (