0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor(connector): apply new design (#1817)

* feat(core,connector-core): add connector core (#1803)

* feat(core,connector-core): add connector core

* fix: create connector function

* refactor(connector): change connectors dependency from connector-types to connector-core (#1812)

* refactor(connector,core): change the connectors dependency from connector-types to connector-core

* refactor(core): do not need to test validator for specific connector implementation

* refactor(connector): remove unnecessary code snippets

* refactor(connector): keep UT placeholder for passwordless connectors

Co-authored-by: wangsijie <wangsijie@silverhand.io>

* fix(core): fix IT description and undestructure error (#1818)

fix(connector): fix connector routes and IT typos

* fix(connector): remove @logto/connector-types as it will not be used anymore (#1819)

fix(connector): remove @logto/connector-types as it will not be used anymore

* chore(connector): rename db in logto connector (#1821)

chore(connector): rename LogtoConnector db to dbEntry

Co-authored-by: Darcy Ye <darcyye@silverhand.io>
This commit is contained in:
Wang Sijie 2022-08-26 16:25:08 +08:00 committed by GitHub
parent 82cd31545d
commit 8db355287c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
112 changed files with 1914 additions and 2942 deletions

View file

@ -5,6 +5,6 @@ module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [...rules['type-enum'][2], 'api', 'release']],
'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps']]
'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps', 'connector-core']]
},
};

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'alipay://'; // This is used to arouse the native Alipay App
export const alipayEndpoint = 'https://openapi.alipay.com/gateway.do';

View file

@ -1,51 +1,11 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import AlipayNativeConnector from '.';
import createConnector, { getAccessToken } from '.';
import { alipayEndpoint } from './constant';
import { mockedAlipayNativeConfig, mockedAlipayNativeConfigWithValidPrivateKey } from './mock';
import { AlipayNativeConfig } from './types';
import { mockedAlipayNativeConfigWithValidPrivateKey } from './mock';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const alipayNativeMethods = new AlipayNativeConnector(getConnectorConfig);
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<AlipayNativeConfig> = alipayNativeMethods.validateConfig;
expect(() => {
validator(mockedAlipayNativeConfig);
}).not.toThrow();
});
it('should fail on empty config', async () => {
const validator: ValidateConfig<AlipayNativeConfig> = alipayNativeMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
it('should fail when missing required properties', async () => {
const validator: ValidateConfig<AlipayNativeConfig> = alipayNativeMethods.validateConfig;
expect(() => {
validator({ appId: 'appId' });
}).toThrow();
});
});
const getConfig = jest.fn().mockResolvedValue(mockedAlipayNativeConfigWithValidPrivateKey);
describe('getAuthorizationUri', () => {
afterEach(() => {
@ -53,10 +13,8 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by state', async () => {
jest
.spyOn(alipayNativeMethods, 'getConfig')
.mockResolvedValueOnce(mockedAlipayNativeConfigWithValidPrivateKey);
const authorizationUri = await alipayNativeMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'dummy-state',
redirectUri: 'dummy-redirect-uri',
});
@ -86,17 +44,25 @@ describe('getAccessToken', () => {
},
sign: '<signature>',
});
const response = await alipayNativeMethods.getAccessToken(
'code',
mockedAlipayNativeConfigWithValidPrivateKey
);
const response = await getAccessToken('code', mockedAlipayNativeConfigWithValidPrivateKey);
const { accessToken } = response;
expect(accessToken).toEqual('access_token');
});
it('throw General error if auth_code not provided in input', async () => {
await expect(alipayNativeMethods.getUserInfo({})).rejects.toMatchError(
nock(alipayEndpointUrl.origin)
.post(alipayEndpointUrl.pathname)
.query(true)
.reply(200, {
error_response: {
code: '20001',
msg: 'Invalid code',
sub_code: 'isv.code-invalid ',
},
sign: '<signature>',
});
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({})).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
});
@ -115,9 +81,8 @@ describe('getAccessToken', () => {
},
sign: '<signature>',
});
await expect(
alipayNativeMethods.getAccessToken('code', mockedAlipayNativeConfigWithValidPrivateKey)
getAccessToken('code', mockedAlipayNativeConfigWithValidPrivateKey)
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
});
@ -133,9 +98,8 @@ describe('getAccessToken', () => {
},
sign: '<signature>',
});
await expect(
alipayNativeMethods.getAccessToken('wrong_code', mockedAlipayNativeConfigWithValidPrivateKey)
getAccessToken('wrong_code', mockedAlipayNativeConfigWithValidPrivateKey)
).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid code')
);
@ -144,12 +108,20 @@ describe('getAccessToken', () => {
describe('getUserInfo', () => {
beforeEach(() => {
jest
.spyOn(alipayNativeMethods, 'getConfig')
.mockResolvedValue(mockedAlipayNativeConfigWithValidPrivateKey);
jest
.spyOn(alipayNativeMethods, 'getAccessToken')
.mockResolvedValue({ accessToken: 'access_token' });
nock(alipayEndpointUrl.origin)
.post(alipayEndpointUrl.pathname)
.query(true)
.once()
.reply(200, {
alipay_system_oauth_token_response: {
user_id: '2088000000000000',
access_token: 'access_token',
expires_in: 3600,
refresh_token: 'refresh_token',
re_expires_in: 7200, // Expiration timeout of refresh token, in seconds
},
sign: '<signature>',
});
});
afterEach(() => {
@ -173,8 +145,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
const { id, name, avatar } = await alipayNativeMethods.getUserInfo({ auth_code: 'code' });
const connector = await createConnector({ getConfig });
const { id, name, avatar } = await connector.getUserInfo({ auth_code: 'code' });
expect(id).toEqual('2088000000000000');
expect(name).toEqual('PlayboyEric');
expect(avatar).toEqual('https://www.alipay.com/xxx.jpg');
@ -193,8 +165,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayNativeMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token')
);
});
@ -212,8 +184,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayNativeMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid auth code')
);
});
@ -231,8 +203,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayNativeMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter',
code: '40002',
@ -256,15 +228,15 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayNativeMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
});
it('should throw with other request errors', async () => {
nock(alipayEndpointUrl.origin).post(alipayEndpointUrl.pathname).query(true).reply(500);
await expect(alipayNativeMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toThrow();
});
});

View file

@ -12,13 +12,13 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
GetAuthorizationUri,
GetUserInfo,
SocialConnectorInstance,
GetConnectorConfig,
} from '@logto/connector-types';
CreateConnector,
SocialConnector,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import dayjs from 'dayjs';
import got from 'got';
@ -46,38 +46,11 @@ import { signingParameters } from './utils';
export type { AlipayNativeConfig } from './types';
export default class AlipayNativeConnector implements SocialConnectorInstance<AlipayNativeConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
private readonly signingParameters = signingParameters;
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AlipayNativeConfig {
const result = alipayNativeConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayNativeConfig>(config, alipayNativeConfigGuard);
const { appId } = config;
@ -86,51 +59,53 @@ export default class AlipayNativeConnector implements SocialConnectorInstance<Al
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (code: string, config: AlipayNativeConfig) => {
const initSearchParameters = {
method: methodForAccessToken,
format: 'JSON',
timestamp: dayjs().format(timestampFormat),
version: '1.0',
grant_type: 'authorization_code',
code,
charset: 'UTF8',
...config,
};
const signedSearchParameters = this.signingParameters(initSearchParameters);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
timeout: defaultTimeout,
});
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert(
alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg)
);
const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
export const getAccessToken = async (code: string, config: AlipayNativeConfig) => {
const initSearchParameters = {
method: methodForAccessToken,
format: 'JSON',
timestamp: dayjs().format(timestampFormat),
version: '1.0',
grant_type: 'authorization_code',
code,
charset: 'UTF8',
...config,
};
const signedSearchParameters = signingParameters(initSearchParameters);
public getUserInfo: GetUserInfo = async (data) => {
const { auth_code } = await this.authorizationCallbackHandler(data);
const config = await this.getConfig(this.metadata.id);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
timeout: defaultTimeout,
});
this.validateConfig(config);
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const { accessToken } = await this.getAccessToken(auth_code, config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert(
alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg)
);
const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { auth_code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayNativeConfig>(config, alipayNativeConfigGuard);
const { accessToken } = await getAccessToken(auth_code, config);
assert(
accessToken && config,
@ -148,7 +123,7 @@ export default class AlipayNativeConnector implements SocialConnectorInstance<Al
charset: 'UTF8',
...config,
};
const signedSearchParameters = this.signingParameters(initSearchParameters);
const signedSearchParameters = signingParameters(initSearchParameters);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
@ -163,7 +138,7 @@ export default class AlipayNativeConnector implements SocialConnectorInstance<Al
const { alipay_user_info_share_response } = result.data;
this.errorHandler(alipay_user_info_share_response);
errorHandler(alipay_user_info_share_response);
const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response;
@ -174,36 +149,46 @@ export default class AlipayNativeConnector implements SocialConnectorInstance<Al
return { id, avatar, name };
};
private readonly errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
if (sub_code) {
assert(
!invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg)
);
if (sub_code) {
assert(
!invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg)
);
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg,
code,
sub_code,
sub_msg,
});
}
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg,
code,
sub_code,
sub_msg,
});
}
};
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createAlipayNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: alipayNativeConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
}
export default createAlipayNativeConnector;
/* eslint-enable unicorn/text-encoding-identifier-case */

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,6 +1,6 @@
// FIXME: @Darcy
/* eslint-disable unicorn/text-encoding-identifier-case */
import { ConnectorType, ConnectorMetadata, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm';
export const alipayEndpoint = 'https://openapi.alipay.com/gateway.do';

View file

@ -1,55 +1,11 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import AlipayConnector from '.';
import createConnector, { getAccessToken } from '.';
import { alipayEndpoint, authorizationEndpoint } from './constant';
import { mockedAlipayConfig, mockedAlipayConfigWithValidPrivateKey } from './mock';
import { AlipayConfig } from './types';
import { mockedAlipayConfigWithValidPrivateKey } from './mock';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const alipayMethods = new AlipayConnector(getConnectorConfig);
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<AlipayConfig> = alipayMethods.validateConfig;
expect(() => {
validator({
appId: 'appId',
privateKey: 'privateKey',
signType: 'RSA',
});
}).not.toThrow();
});
it('should fail on empty config', async () => {
const validator: ValidateConfig<AlipayConfig> = alipayMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
it('should fail when missing required properties', async () => {
const validator: ValidateConfig<AlipayConfig> = alipayMethods.validateConfig;
expect(() => {
validator({ appId: 'appId' });
}).toThrow();
});
});
const getConfig = jest.fn().mockResolvedValue(mockedAlipayConfigWithValidPrivateKey);
describe('getAuthorizationUri', () => {
afterEach(() => {
@ -57,8 +13,8 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
jest.spyOn(alipayMethods, 'getConfig').mockResolvedValueOnce(mockedAlipayConfig);
const authorizationUri = await alipayMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3001/callback',
});
@ -90,11 +46,7 @@ describe('getAccessToken', () => {
},
sign: '<signature>',
});
const response = await alipayMethods.getAccessToken(
'code',
mockedAlipayConfigWithValidPrivateKey
);
const response = await getAccessToken('code', mockedAlipayConfigWithValidPrivateKey);
const { accessToken } = response;
expect(accessToken).toEqual('access_token');
});
@ -115,7 +67,7 @@ describe('getAccessToken', () => {
});
await expect(
alipayMethods.getAccessToken('code', mockedAlipayConfigWithValidPrivateKey)
getAccessToken('code', mockedAlipayConfigWithValidPrivateKey)
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
});
@ -133,7 +85,7 @@ describe('getAccessToken', () => {
});
await expect(
alipayMethods.getAccessToken('wrong_code', mockedAlipayConfigWithValidPrivateKey)
getAccessToken('wrong_code', mockedAlipayConfigWithValidPrivateKey)
).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid code')
);
@ -141,16 +93,28 @@ describe('getAccessToken', () => {
});
describe('getUserInfo', () => {
beforeEach(() => {
jest.spyOn(alipayMethods, 'getConfig').mockResolvedValue(mockedAlipayConfigWithValidPrivateKey);
jest.spyOn(alipayMethods, 'getAccessToken').mockResolvedValue({ accessToken: 'access_token' });
});
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
beforeEach(() => {
nock(alipayEndpointUrl.origin)
.post(alipayEndpointUrl.pathname)
.query(true)
.once()
.reply(200, {
alipay_system_oauth_token_response: {
user_id: '2088000000000000',
access_token: 'access_token',
expires_in: 3600,
refresh_token: 'refresh_token',
re_expires_in: 7200, // Expiration timeout of refresh token, in seconds
},
sign: '<signature>',
});
});
const alipayEndpointUrl = new URL(alipayEndpoint);
it('should get userInfo with accessToken', async () => {
@ -167,15 +131,16 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
const { id, name, avatar } = await alipayMethods.getUserInfo({ auth_code: 'code' });
const connector = await createConnector({ getConfig });
const { id, name, avatar } = await connector.getUserInfo({ auth_code: 'code' });
expect(id).toEqual('2088000000000000');
expect(name).toEqual('PlayboyEric');
expect(avatar).toEqual('https://www.alipay.com/xxx.jpg');
});
it('throw General error if auth_code not provided in input', async () => {
await expect(alipayMethods.getUserInfo({})).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({})).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse, '{}')
);
});
@ -193,8 +158,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'Invalid auth token')
);
});
@ -212,8 +177,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'Invalid auth code')
);
});
@ -231,8 +196,8 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayMethods.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'wrong_code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'Invalid parameter',
code: '40002',
@ -256,15 +221,15 @@ describe('getUserInfo', () => {
},
sign: '<signature>',
});
await expect(alipayMethods.getUserInfo({ auth_code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
});
it('should throw with other request errors', async () => {
nock(alipayEndpointUrl.origin).post(alipayEndpointUrl.pathname).query(true).reply(500);
await expect(alipayMethods.getUserInfo({ auth_code: 'code' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ auth_code: 'code' })).rejects.toThrow();
});
});

View file

@ -8,13 +8,13 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
GetConnectorConfig,
GetAuthorizationUri,
GetUserInfo,
SocialConnectorInstance,
GetConnectorConfig,
} from '@logto/connector-types';
CreateConnector,
SocialConnector,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import dayjs from 'dayjs';
import got from 'got';
@ -44,38 +44,11 @@ import { signingParameters } from './utils';
export type { AlipayConfig } from './types';
export default class AlipayConnector implements SocialConnectorInstance<AlipayConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
private readonly signingParameters = signingParameters;
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AlipayConfig {
const result = alipayConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayConfig>(config, alipayConfigGuard);
const { appId: app_id } = config;
@ -91,52 +64,53 @@ export default class AlipayConnector implements SocialConnectorInstance<AlipayCo
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (code: string, config: AlipayConfig) => {
const { charset, ...rest } = config;
const initSearchParameters = {
method: methodForAccessToken,
format: 'JSON',
timestamp: dayjs().format(timestampFormat),
version: '1.0',
grant_type: 'authorization_code',
code,
...rest,
charset: charset ?? fallbackCharset,
};
const signedSearchParameters = this.signingParameters(initSearchParameters);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
timeout: defaultTimeout,
});
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert(
alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg)
);
const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
export const getAccessToken = async (code: string, config: AlipayConfig) => {
const { charset, ...rest } = config;
const initSearchParameters = {
method: methodForAccessToken,
format: 'JSON',
timestamp: dayjs().format(timestampFormat),
version: '1.0',
grant_type: 'authorization_code',
code,
...rest,
charset: charset ?? fallbackCharset,
};
const signedSearchParameters = signingParameters(initSearchParameters);
public getUserInfo: GetUserInfo = async (data) => {
const { auth_code } = await this.authorizationCallbackHandler(data);
const config = await this.getConfig(this.metadata.id);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
timeout: defaultTimeout,
});
this.validateConfig(config);
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const { accessToken } = await this.getAccessToken(auth_code, config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { error_response, alipay_system_oauth_token_response } = result.data;
const { msg, sub_msg } = error_response ?? {};
assert(
alipay_system_oauth_token_response,
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg ?? sub_msg)
);
const { access_token: accessToken } = alipay_system_oauth_token_response;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { auth_code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<AlipayConfig>(config, alipayConfigGuard);
const { accessToken } = await getAccessToken(auth_code, config);
assert(
accessToken && config,
@ -155,7 +129,7 @@ export default class AlipayConnector implements SocialConnectorInstance<AlipayCo
...rest,
charset: charset ?? fallbackCharset,
};
const signedSearchParameters = this.signingParameters(initSearchParameters);
const signedSearchParameters = signingParameters(initSearchParameters);
const httpResponse = await got.post(alipayEndpoint, {
searchParams: signedSearchParameters,
@ -172,7 +146,7 @@ export default class AlipayConnector implements SocialConnectorInstance<AlipayCo
const { alipay_user_info_share_response } = result.data;
this.errorHandler(alipay_user_info_share_response);
errorHandler(alipay_user_info_share_response);
const { user_id: id, avatar, nick_name: name } = alipay_user_info_share_response;
@ -183,38 +157,45 @@ export default class AlipayConnector implements SocialConnectorInstance<AlipayCo
return { id, avatar, name };
};
private readonly errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
const errorHandler: ErrorHandler = ({ code, msg, sub_code, sub_msg }) => {
if (invalidAccessTokenCode.includes(code)) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, msg);
}
if (sub_code) {
assert(
!invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg)
);
if (sub_code) {
assert(
!invalidAccessTokenSubCode.includes(sub_code),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, msg)
);
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg,
code,
sub_code,
sub_msg,
});
}
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: msg,
code,
sub_code,
sub_msg,
});
}
};
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(parameterObject));
}
return result.data;
};
const createAlipayConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: alipayConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const dataGuard = z.object({ auth_code: z.string() });
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
JSON.stringify(parameterObject)
);
}
return result.data;
};
}
export default createAlipayConnector;

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const endpoint = 'https://dm.aliyuncs.com/';

View file

@ -1,13 +1,10 @@
import { GetConnectorConfig, ValidateConfig } from '@logto/connector-types';
import { MessageTypes } from '@logto/connector-core';
import AliyunDmConnector from '.';
import createConnector from '.';
import { mockedConfig } from './mock';
import { singleSendMail } from './single-send-mail';
import { AliyunDmConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const aliyunDmMethods = new AliyunDmConnector(getConnectorConfig);
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
jest.mock('./single-send-mail', () => {
return {
@ -20,47 +17,18 @@ jest.mock('./single-send-mail', () => {
};
});
beforeAll(() => {
jest.spyOn(aliyunDmMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<AliyunDmConfig> = aliyunDmMethods.validateConfig;
expect(() => {
validator({
accessKeyId: 'accessKeyId',
accessKeySecret: 'accessKeySecret',
accountName: 'accountName',
templates: [],
});
}).not.toThrow();
});
it('should fail if config is invalid', async () => {
const validator: ValidateConfig<AliyunDmConfig> = aliyunDmMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
});
describe('sendMessage()', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should call singleSendMail() and replace code in content', async () => {
await aliyunDmMethods.sendMessage('to@email.com', 'SignIn', { code: '1234' });
const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: 'to@email.com',
type: MessageTypes.SignIn,
payload: { code: '1234' },
});
expect(singleSendMail).toHaveBeenCalledWith(
expect.objectContaining({
HtmlBody: 'Your code is 1234, 1234 is your code',
@ -70,8 +38,13 @@ describe('sendMessage()', () => {
});
it('throws if template is missing', async () => {
const connector = await createConnector({ getConfig });
await expect(
aliyunDmMethods.sendMessage('to@email.com', 'Register', { code: '1234' })
connector.sendMessage({
to: 'to@email.com',
type: MessageTypes.Register,
payload: { code: '1234' },
})
).rejects.toThrow();
});
});

View file

@ -1,14 +1,12 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
EmailSendMessageFunction,
EmailSendTestMessageFunction,
EmailConnectorInstance,
CreateConnector,
EmailConnector,
GetConnectorConfig,
EmailMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import { HTTPError } from 'got';
@ -21,52 +19,13 @@ import {
sendMailErrorResponseGuard,
} from './types';
export default class AliyunDmConnector implements EmailConnectorInstance<AliyunDmConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AliyunDmConfig {
const result = aliyunDmConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const emailConfig = await this.getConfig(this.metadata.id);
this.validateConfig(emailConfig);
return this.sendMessageBy(emailConfig, address, type, data);
};
public sendTestMessage: EmailSendTestMessageFunction = async (config, address, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
private readonly sendMessageBy = async (
config: AliyunDmConfig,
address: string,
type: keyof EmailMessageTypes,
data: EmailMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
// eslint-disable-next-line complexity
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<AliyunDmConfig>(config, aliyunDmConfigGuard);
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -85,12 +44,12 @@ export default class AliyunDmConnector implements EmailConnectorInstance<AliyunD
AccountName: accountName,
ReplyToAddress: 'false',
AddressType: '1',
ToAddress: address,
ToAddress: to,
FromAlias: fromAlias,
Subject: template.subject,
HtmlBody:
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
typeof payload.code === 'string'
? template.content.replace(/{{code}}/g, payload.code)
: template.content,
},
accessKeySecret
@ -114,22 +73,31 @@ export default class AliyunDmConnector implements EmailConnectorInstance<AliyunD
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
this.errorHandler(rawBody);
errorHandler(rawBody);
}
throw error;
}
};
private readonly errorHandler = (errorResponseBody: string) => {
const result = sendMailErrorResponseGuard.safeParse(JSON.parse(errorResponseBody));
const errorHandler = (errorResponseBody: string) => {
const result = sendMailErrorResponseGuard.safeParse(JSON.parse(errorResponseBody));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { Message: errorDescription, ...rest } = result.data;
const { Message: errorDescription, ...rest } = result.data;
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription, ...rest });
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription, ...rest });
};
const createAliyunDmConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: aliyunDmConfigGuard,
sendMessage: sendMessage(getConfig),
};
}
};
export default createAliyunDmConnector;

View file

@ -24,8 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType } from '@logto/connector-core';
export const endpoint = 'https://dysmsapi.aliyuncs.com/';

View file

@ -1,13 +1,10 @@
import { GetConnectorConfig, ValidateConfig } from '@logto/connector-types';
import { MessageTypes } from '@logto/connector-core';
import AliyunSmsConnector from '.';
import createConnector from '.';
import { mockedConnectorConfig, mockedValidConnectorConfig, phoneTest, codeTest } from './mock';
import { sendSms } from './single-send-text';
import { AliyunSmsConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const aliyunSmsMethods = new AliyunSmsConnector(getConnectorConfig);
const getConfig = jest.fn().mockResolvedValue(mockedConnectorConfig);
jest.mock('./single-send-text', () => {
return {
@ -20,42 +17,18 @@ jest.mock('./single-send-text', () => {
};
});
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<AliyunSmsConfig> = aliyunSmsMethods.validateConfig;
expect(() => {
validator(mockedValidConnectorConfig);
}).not.toThrow();
});
it('should fail if config is invalid', async () => {
const validator: ValidateConfig<AliyunSmsConfig> = aliyunSmsMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
});
describe('sendMessage()', () => {
beforeEach(() => {
jest.spyOn(aliyunSmsMethods, 'getConfig').mockResolvedValueOnce(mockedConnectorConfig);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call singleSendMail() and replace code in content', async () => {
await aliyunSmsMethods.sendMessage(phoneTest, 'SignIn', { code: codeTest });
const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: phoneTest,
type: MessageTypes.SignIn,
payload: { code: codeTest },
});
expect(sendSms).toHaveBeenCalledWith(
expect.objectContaining({
AccessKeyId: mockedConnectorConfig.accessKeyId,
@ -69,8 +42,13 @@ describe('sendMessage()', () => {
});
it('throws if template is missing', async () => {
const connector = await createConnector({ getConfig });
await expect(
aliyunSmsMethods.sendMessage(phoneTest, 'Register', { code: codeTest })
connector.sendMessage({
to: phoneTest,
type: MessageTypes.Register,
payload: { code: codeTest },
})
).rejects.toThrow();
});
});

View file

@ -1,14 +1,12 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
SmsSendMessageFunction,
SmsSendTestMessageFunction,
SmsConnectorInstance,
GetConnectorConfig,
SmsMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
SmsConnector,
CreateConnector,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import { HTTPError } from 'got';
@ -16,52 +14,12 @@ import { defaultMetadata } from './constant';
import { sendSms } from './single-send-text';
import { aliyunSmsConfigGuard, AliyunSmsConfig, sendSmsResponseGuard } from './types';
export default class AliyunSmsConnector implements SmsConnectorInstance<AliyunSmsConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AliyunSmsConfig {
const result = aliyunSmsConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: SmsSendMessageFunction = async (phone, type, data) => {
const smsConfig = await this.getConfig(this.metadata.id);
this.validateConfig(smsConfig);
return this.sendMessageBy(smsConfig, phone, type, data);
};
public sendTestMessage: SmsSendTestMessageFunction = async (config, phone, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, phone, type, data);
};
private readonly sendMessageBy = async (
config: AliyunSmsConfig,
phone: string,
type: keyof SmsMessageTypes,
data: SmsMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<AliyunSmsConfig>(config, aliyunSmsConfigGuard);
const { accessKeyId, accessKeySecret, signName, templates } = config;
const template = templates.find(({ usageType }) => usageType === type);
@ -74,17 +32,17 @@ export default class AliyunSmsConnector implements SmsConnectorInstance<AliyunSm
const httpResponse = await sendSms(
{
AccessKeyId: accessKeyId,
PhoneNumbers: phone,
PhoneNumbers: to,
SignName: signName,
TemplateCode: template.templateCode,
TemplateParam: JSON.stringify(data),
TemplateParam: JSON.stringify(payload),
},
accessKeySecret
);
const { body: rawBody } = httpResponse;
const { Code, Message, ...rest } = this.parseResponseString(rawBody);
const { Code, Message, ...rest } = parseResponseString(rawBody);
if (Code !== 'OK') {
throw new ConnectorError(ConnectorErrorCodes.General, {
@ -106,7 +64,7 @@ export default class AliyunSmsConnector implements SmsConnectorInstance<AliyunSm
assert(typeof rawBody === 'string', new ConnectorError(ConnectorErrorCodes.InvalidResponse));
const { Code, Message, ...rest } = this.parseResponseString(rawBody);
const { Code, Message, ...rest } = parseResponseString(rawBody);
throw new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: Message,
@ -116,13 +74,22 @@ export default class AliyunSmsConnector implements SmsConnectorInstance<AliyunSm
}
};
private readonly parseResponseString = (response: string) => {
const result = sendSmsResponseGuard.safeParse(JSON.parse(response));
const parseResponseString = (response: string) => {
const result = sendSmsResponseGuard.safeParse(JSON.parse(response));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
return result.data;
return result.data;
};
const createAliyunSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: aliyunSmsConfigGuard,
sendMessage: sendMessage(getConfig),
};
}
};
export default createAliyunSmsConnector;

View file

@ -25,8 +25,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
// https://appleid.apple.com/.well-known/openid-configuration
export const issuer = 'https://appleid.apple.com';

View file

@ -1,36 +1,25 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import { jwtVerify } from 'jose';
import AppleConnector from '.';
import createConnector from '.';
import { authorizationEndpoint } from './constant';
import { mockedConfig } from './mock';
import { AppleConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const appleMethods = new AppleConnector(getConnectorConfig);
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
jest.mock('jose', () => ({
jwtVerify: jest.fn(),
createRemoteJWKSet: jest.fn(),
}));
beforeAll(() => {
jest.spyOn(appleMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
describe('getAuthorizationUri', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await appleMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
@ -40,31 +29,6 @@ describe('getAuthorizationUri', () => {
});
});
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should be true on valid config', async () => {
const validator: ValidateConfig<AppleConfig> = appleMethods.validateConfig;
expect(() => {
validator({ clientId: 'clientId' });
}).not.toThrow();
});
it('should be false on empty config', async () => {
const validator: ValidateConfig<AppleConfig> = appleMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
});
describe('getUserInfo', () => {
afterAll(() => {
jest.clearAllMocks();
@ -74,12 +38,14 @@ describe('getUserInfo', () => {
const userId = 'userId';
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: userId } }));
const userInfo = await appleMethods.getUserInfo({ id_token: 'idToken' });
const connector = await createConnector({ getConfig });
const userInfo = await connector.getUserInfo({ id_token: 'idToken' });
expect(userInfo).toEqual({ id: userId });
});
it('should throw if id token is missing', async () => {
await expect(appleMethods.getUserInfo({})).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({})).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
});
@ -89,7 +55,8 @@ describe('getUserInfo', () => {
mockJwtVerify.mockImplementationOnce(() => {
throw new Error('jwtVerify failed');
});
await expect(appleMethods.getUserInfo({ id_token: 'id_token' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ id_token: 'id_token' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid)
);
});
@ -99,7 +66,8 @@ describe('getUserInfo', () => {
mockJwtVerify.mockImplementationOnce(() => ({
payload: { iat: 123_456 },
}));
await expect(appleMethods.getUserInfo({ id_token: 'id_token' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ id_token: 'id_token' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid)
);
});

View file

@ -1,49 +1,26 @@
import {
ConnectorMetadata,
GetAuthorizationUri,
GetUserInfo,
ConnectorError,
ConnectorErrorCodes,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
} from '@logto/connector-types';
validateConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer, authorizationEndpoint } from './constant';
import { appleConfigGuard, AppleConfig, dataGuard } from './types';
// TO-DO: support nonce validation
export default class AppleConnector implements SocialConnectorInstance<AppleConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AppleConfig {
const result = appleConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
validateConfig<AppleConfig>(config, appleConfigGuard);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -58,16 +35,17 @@ export default class AppleConnector implements SocialConnectorInstance<AppleConf
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getUserInfo: GetUserInfo = async (data) => {
const { id_token: idToken } = await this.authorizationCallbackHandler(data);
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { id_token: idToken } = await authorizationCallbackHandler(data);
if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
}
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const config = await getConfig(defaultMetadata.id);
validateConfig<AppleConfig>(config, appleConfigGuard);
const { clientId } = config;
@ -89,13 +67,23 @@ export default class AppleConnector implements SocialConnectorInstance<AppleConf
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = dataGuard.safeParse(parameterObject);
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = dataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
return result.data;
};
const createAppleConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: appleConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
}
};
export default createAppleConnector;

View file

@ -25,8 +25,7 @@
},
"dependencies": {
"@azure/msal-node": "^1.12.0",
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const graphAPIEndpoint = 'https://graph.microsoft.com/v1.0/me';
export const scopes = ['User.Read'];

View file

@ -1,11 +1,11 @@
import { GetConnectorConfig } from '@logto/connector-types';
import { GetConnectorConfig } from '@logto/connector-core';
import AzureADConnector from '.';
import createConnector from '.';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
describe('Azure AD connector', () => {
it('init without exploding', () => {
expect(() => new AzureADConnector(getConnectorConfig)).not.toThrow();
expect(async () => createConnector({ getConfig: getConnectorConfig })).not.toThrow();
});
});

View file

@ -11,12 +11,11 @@ import {
ConnectorErrorCodes,
GetAuthorizationUri,
GetUserInfo,
ConnectorMetadata,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
validateConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { assert, conditional } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -26,54 +25,34 @@ import {
AzureADConfig,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
} from './types';
export default class AzureADConnector implements SocialConnectorInstance<AzureADConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
// eslint-disable-next-line @silverhand/fp/no-let
let clientApplication: ConfidentialClientApplication;
// eslint-disable-next-line @silverhand/fp/no-let
let authCodeRequest: AuthorizationCodeRequest;
public clientApplication!: ConfidentialClientApplication;
public authCodeUrlParams!: AuthorizationUrlRequest;
// This `cryptoProvider` seems not used.
// Temporarily keep this as this is a refactor, which should not change the logics.
const cryptoProvider = new CryptoProvider();
cryptoProvider = new CryptoProvider();
private readonly authCodeRequest!: AuthorizationCodeRequest;
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AzureADConfig {
const result = azureADConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
validateConfig<AzureADConfig>(config, azureADConfigGuard);
const { clientId, clientSecret, cloudInstance, tenantId } = config;
this.authCodeUrlParams = {
const defaultAuthCodeUrlParameters: AuthorizationUrlRequest = {
scopes,
state,
redirectUri,
};
this.clientApplication = new ConfidentialClientApplication({
// eslint-disable-next-line @silverhand/fp/no-mutation
clientApplication = new ConfidentialClientApplication({
auth: {
clientId,
clientSecret,
@ -82,44 +61,47 @@ export default class AzureADConnector implements SocialConnectorInstance<AzureAD
});
const authCodeUrlParameters = {
...this.authCodeUrlParams,
...defaultAuthCodeUrlParameters,
};
const authCodeUrl = await this.clientApplication.getAuthCodeUrl(authCodeUrlParameters);
const authCodeUrl = await clientApplication.getAuthCodeUrl(authCodeUrlParameters);
return authCodeUrl;
};
public getAccessToken = async (code: string, redirectUri: string) => {
const codeRequest = {
...this.authCodeRequest,
redirectUri,
scopes: ['User.Read'],
code,
};
const authResult = await this.clientApplication.acquireTokenByCode(codeRequest);
const result = accessTokenResponseGuard.safeParse(authResult);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
const getAccessToken = async (code: string, redirectUri: string) => {
const codeRequest: AuthorizationCodeRequest = {
...authCodeRequest,
redirectUri,
scopes: ['User.Read'],
code,
};
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = await this.authorizationCallbackHandler(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
const authResult = await clientApplication.acquireTokenByCode(codeRequest);
const config = await this.getConfig(this.metadata.id);
const result = accessTokenResponseGuard.safeParse(authResult);
this.validateConfig(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const { accessToken } = await getAccessToken(code, redirectUri);
// This `config` seems not used. `config` are used to initialize `clientApplication`.
// Temporarily keep this as this is a refactor, which should not change the logics.
const config = await getConfig(defaultMetadata.id);
validateConfig<AzureADConfig>(config, azureADConfigGuard);
try {
const httpResponse = await got.get(graphAPIEndpoint, {
@ -157,13 +139,23 @@ export default class AzureADConnector implements SocialConnectorInstance<AzureAD
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeWithRedirectDataGuard.safeParse(parameterObject);
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
return result.data;
};
const createAzureAdConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: azureADConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
}
};
export default createAzureAdConnector;

View file

@ -32,3 +32,8 @@ export const userInfoResponseGuard = z.object({
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export const authResponseGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});

View file

@ -1,41 +1,39 @@
{
"name": "@logto/connector-types",
"name": "@logto/connector-core",
"version": "1.0.0-beta.5",
"main": "lib/index.js",
"main": "./lib/index.js",
"exports": "./lib/index.js",
"typings": "./lib/index.d.ts",
"author": "Silverhand Inc. <contact@silverhand.io>",
"license": "MPL-2.0",
"private": true,
"files": [
"lib"
],
"scripts": {
"precommit": "lint-staged",
"build": "rm -rf lib/ && tsc --p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
"dev": "tsc --watch --preserveWatchOutput --incremental",
"build": "rm -rf lib/ && tsc",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"prepack": "pnpm build"
},
"engines": {
"node": "^16.0.0"
},
"dependencies": {
"@logto/phrases": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"zod": "^3.14.3"
},
"devDependencies": {
"@jest/types": "^28.1.3",
"@shopify/jest-koa-mocks": "^5.0.0",
"@silverhand/eslint-config": "1.0.0-rc.2",
"@silverhand/essentials": "^1.2.0",
"@silverhand/ts-config": "1.0.0-rc.2",
"@types/jest": "^28.1.6",
"@types/node": "^16.3.1",
"eslint": "^8.21.0",
"jest": "^28.1.3",
"lint-staged": "^13.0.0",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.0.0"
},
"eslintConfig": {
"extends": "@silverhand"
},

View file

@ -0,0 +1,13 @@
import { ZodType } from 'zod';
import { ConnectorError, ConnectorErrorCodes } from './types';
export * from './types';
export function validateConfig<T>(config: unknown, guard: ZodType): asserts config is T {
const result = guard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}

View file

@ -0,0 +1,107 @@
import type { Language } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
import { z, ZodType } from 'zod';
export enum ConnectorType {
Email = 'Email',
SMS = 'SMS',
Social = 'Social',
}
export enum ConnectorPlatform {
Native = 'Native',
Universal = 'Universal',
Web = 'Web',
}
type I18nPhrases = { [Language.English]: string } & {
[key in Exclude<Language, Language.English>]?: string;
};
export type ConnectorMetadata = {
id: string;
target: string;
type: ConnectorType;
platform: Nullable<ConnectorPlatform>;
name: I18nPhrases;
logo: string;
logoDark: Nullable<string>;
description: I18nPhrases;
readme: string;
configTemplate: string;
};
export enum ConnectorErrorCodes {
General,
InvalidMetadata,
InvalidConfigGuard,
InsufficientRequestParameters,
InvalidConfig,
InvalidResponse,
TemplateNotFound,
NotImplemented,
SocialAuthCodeInvalid,
SocialAccessTokenInvalid,
SocialIdTokenInvalid,
AuthorizationFailed,
}
export class ConnectorError extends Error {
public code: ConnectorErrorCodes;
public data: unknown;
constructor(code: ConnectorErrorCodes, data?: unknown) {
const message = typeof data === 'string' ? data : 'Connector error occurred.';
super(message);
this.code = code;
this.data = typeof data === 'string' ? { message: data } : data;
}
}
export enum MessageTypes {
SignIn = 'SignIn',
Register = 'Register',
ForgotPassword = 'ForgotPassword',
Test = 'Test',
}
export const messageTypesGuard = z.nativeEnum(MessageTypes);
export type BaseConnector = {
metadata: ConnectorMetadata;
configGuard: ZodType;
};
export type SmsConnector = {
sendMessage: SendMessageFunction;
} & BaseConnector;
export type EmailConnector = SmsConnector;
export type SocialConnector = {
getAuthorizationUri: GetAuthorizationUri;
getUserInfo: GetUserInfo;
} & BaseConnector;
export type GeneralConnector = BaseConnector &
Partial<SocialConnector & EmailConnector & SmsConnector>;
export type CreateConnector<
T extends SocialConnector | EmailConnector | SmsConnector | GeneralConnector
> = (options: { getConfig: GetConnectorConfig }) => Promise<T>;
export type SendMessageFunction = (
data: { to: string; type: MessageTypes; payload: { code: string } },
config?: unknown
) => Promise<unknown>;
export type GetAuthorizationUri = (payload: {
state: string;
redirectUri: string;
}) => Promise<string>;
export type GetUserInfo = (
data: unknown
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig = (id: string) => Promise<unknown>;

View file

@ -2,10 +2,9 @@
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
"declaration": true
},
"include": ["src"]
"include": [
"src"
]
}

View file

@ -24,8 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
/**
* Note: If you do not include a version number we will default to the oldest available version, so it's recommended to include the version number in your requests.

View file

@ -1,56 +1,13 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import FacebookConnector from '.';
import createConnector, { getAccessToken } from '.';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
import { clientId, clientSecret, code, dummyRedirectUri, fields, mockedConfig } from './mock';
import { FacebookConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const facebookMethods = new FacebookConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(facebookMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('Facebook connector', () => {
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<FacebookConfig> = facebookMethods.validateConfig;
expect(() => {
validator({ clientId, clientSecret });
}).not.toThrow();
});
it('should fail on invalid config', async () => {
const validator: ValidateConfig<FacebookConfig> = facebookMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
expect(() => {
validator({ clientId });
}).toThrow();
expect(() => {
validator({ clientSecret });
}).toThrow();
});
});
describe('getAuthorizationUri', () => {
afterEach(() => {
jest.clearAllMocks();
@ -59,7 +16,8 @@ describe('Facebook connector', () => {
it('should get a valid authorizationUri with redirectUri and state', async () => {
const redirectUri = 'http://localhost:3000/callback';
const state = 'some_state';
const authorizationUri = await facebookMethods.getAuthorizationUri({ state, redirectUri });
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({ state, redirectUri });
const encodedRedirectUri = encodeURIComponent(redirectUri);
expect(authorizationUri).toEqual(
@ -90,7 +48,10 @@ describe('Facebook connector', () => {
expires_in: 3600,
});
const { accessToken } = await facebookMethods.getAccessToken(code, dummyRedirectUri);
const { accessToken } = await getAccessToken(mockedConfig, {
code,
redirectUri: dummyRedirectUri,
});
expect(accessToken).toEqual('access_token');
});
@ -110,9 +71,9 @@ describe('Facebook connector', () => {
expires_in: 3600,
});
await expect(facebookMethods.getAccessToken(code, dummyRedirectUri)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
await expect(
getAccessToken(mockedConfig, { code, redirectUri: dummyRedirectUri })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
});
});
@ -150,8 +111,8 @@ describe('Facebook connector', () => {
email: 'octocat@facebook.com',
picture: { data: { url: avatar } },
});
const socialUserInfo = await facebookMethods.getUserInfo({
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({
code,
redirectUri: dummyRedirectUri,
});
@ -165,8 +126,9 @@ describe('Facebook connector', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').query({ fields }).reply(400);
const connector = await createConnector({ getConfig });
await expect(
facebookMethods.getUserInfo({ code, redirectUri: dummyRedirectUri })
connector.getUserInfo({ code, redirectUri: dummyRedirectUri })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
@ -181,8 +143,9 @@ describe('Facebook connector', () => {
email: 'octocat@facebook.com',
picture: { data: { url: avatar } },
});
const connector = await createConnector({ getConfig });
await expect(
facebookMethods.getUserInfo({
connector.getUserInfo({
error: 'access_denied',
error_code: 200,
error_description: 'Permissions error.',
@ -204,8 +167,9 @@ describe('Facebook connector', () => {
email: 'octocat@facebook.com',
picture: { data: { url: avatar } },
});
const connector = await createConnector({ getConfig });
await expect(
facebookMethods.getUserInfo({
connector.getUserInfo({
error: 'general_error',
error_code: 200,
error_description: 'General error encountered.',
@ -223,8 +187,9 @@ describe('Facebook connector', () => {
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
const connector = await createConnector({ getConfig });
await expect(
facebookMethods.getUserInfo({ code, redirectUri: dummyRedirectUri })
connector.getUserInfo({ code, redirectUri: dummyRedirectUri })
).rejects.toThrow();
});
});

View file

@ -6,14 +6,13 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
CreateConnector,
SocialConnector,
GetAuthorizationUri,
GetUserInfo,
SocialConnectorInstance,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -31,38 +30,14 @@ import {
accessTokenResponseGuard,
FacebookConfig,
userInfoResponseGuard,
authResponseGuard,
} from './types';
export default class FacebookConnector implements SocialConnectorInstance<FacebookConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is FacebookConfig {
const result = facebookConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<FacebookConfig>(config, facebookConfigGuard);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -75,39 +50,45 @@ export default class FacebookConnector implements SocialConnectorInstance<Facebo
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (code: string, redirectUri: string) => {
const config = await this.getConfig(this.metadata.id);
export const getAccessToken = async (
config: FacebookConfig,
codeObject: { code: string; redirectUri: string }
) => {
const { code, redirectUri } = codeObject;
validateConfig<FacebookConfig>(config, facebookConfigGuard);
this.validateConfig(config);
const { clientId: client_id, clientSecret: client_secret } = config;
const { clientId: client_id, clientSecret: client_secret } = config;
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: {
code,
client_id,
client_secret,
redirect_uri: redirectUri,
},
timeout: defaultTimeout,
});
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: {
code,
client_id,
client_secret,
redirect_uri: redirectUri,
},
timeout: defaultTimeout,
});
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { access_token: accessToken } = result.data;
const { access_token: accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
return { accessToken };
};
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = await this.authorizationCallbackHandler(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<FacebookConfig>(config, facebookConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri });
try {
const httpResponse = await got.get(userInfoEndpoint, {
@ -149,33 +130,40 @@ export default class FacebookConnector implements SocialConnectorInstance<Facebo
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeWithRedirectDataGuard.safeParse(parameterObject);
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (result.success) {
return result.data;
}
if (result.success) {
return result.data;
}
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
if (!parsedError.success) {
throw new ConnectorError(
ConnectorErrorCodes.InvalidResponse,
JSON.stringify(parameterObject)
);
}
if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(parameterObject));
}
const { error, error_code, error_description, error_reason } = parsedError.data;
const { error, error_code, error_description, error_reason } = parsedError.data;
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
error_code,
errorDescription: error_description,
error_reason,
});
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
error_code,
errorDescription: error_description,
error_reason,
});
};
const createFacebookConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: facebookConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
}
};
export default createFacebookConnector;

View file

@ -1,4 +1,4 @@
import { z } from 'zod';
import { object, z } from 'zod';
export const facebookConfigGuard = z.object({
clientId: z.string(),
@ -36,3 +36,5 @@ export const authorizationCallbackErrorGuard = z.object({
error_description: z.string(),
error_reason: z.string(),
});
export const authResponseGuard = z.object({ code: z.string(), redirectUri: z.string() });

View file

@ -25,9 +25,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",
"got": "^11.8.2",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'https://github.com/login/oauth/authorize';
export const scope = 'read:user';

View file

@ -1,24 +1,12 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import * as qs from 'query-string';
import GithubConnector from '.';
import createConnector, { getAccessToken } from '.';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
import { mockedConfig } from './mock';
import { GithubConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const githubMethods = new GithubConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(githubMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('getAuthorizationUri', () => {
afterEach(() => {
@ -26,7 +14,8 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await githubMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
@ -53,7 +42,7 @@ describe('getAccessToken', () => {
token_type: 'token_type',
})
);
const { accessToken } = await githubMethods.getAccessToken('code');
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' });
expect(accessToken).toEqual('access_token');
});
@ -61,44 +50,12 @@ describe('getAccessToken', () => {
nock(accessTokenEndpoint)
.post('')
.reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' }));
await expect(githubMethods.getAccessToken('code')).rejects.toMatchError(
await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
});
});
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<GithubConfig> = githubMethods.validateConfig;
expect(() => {
validator({ clientId: 'clientId', clientSecret: 'clientSecret' });
}).not.toThrow();
});
it('should fail on empty config', async () => {
const validator: ValidateConfig<GithubConfig> = githubMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
it('should fail when missing clientSecret', async () => {
const validator: ValidateConfig<GithubConfig> = githubMethods.validateConfig;
expect(() => {
validator({ clientId: 'clientId' });
}).toThrow();
});
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint)
@ -125,7 +82,8 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
});
const socialUserInfo = await githubMethods.getUserInfo({ code: 'code' });
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -141,7 +99,8 @@ describe('getUserInfo', () => {
name: null,
email: null,
});
const socialUserInfo = await githubMethods.getUserInfo({ code: 'code' });
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: '1',
});
@ -149,7 +108,8 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).get('').reply(401);
await expect(githubMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
@ -161,8 +121,9 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
});
const connector = await createConnector({ getConfig });
await expect(
githubMethods.getUserInfo({
connector.getUserInfo({
error: 'access_denied',
error_description: 'The user has denied your application access.',
error_uri:
@ -183,8 +144,9 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
});
const connector = await createConnector({ getConfig });
await expect(
githubMethods.getUserInfo({
connector.getUserInfo({
error: 'general_error',
error_description: 'General error encountered.',
})
@ -198,6 +160,7 @@ describe('getUserInfo', () => {
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).get('').reply(500);
await expect(githubMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toThrow();
});
});

View file

@ -1,14 +1,13 @@
import {
ConnectorMetadata,
GetAuthorizationUri,
GetUserInfo,
ConnectorError,
ConnectorErrorCodes,
Connector,
SocialConnectorInstance,
SocialConnector,
CreateConnector,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
validateConfig,
} from '@logto/connector-core';
import { assert, conditional } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
import * as qs from 'query-string';
@ -27,39 +26,14 @@ import {
accessTokenResponseGuard,
GithubConfig,
userInfoResponseGuard,
authResponseGuard,
} from './types';
export default class GithubConnector implements SocialConnectorInstance<GithubConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is GithubConfig {
const result = githubConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<GithubConfig>(config, githubConfigGuard);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
@ -70,39 +44,66 @@ export default class GithubConnector implements SocialConnectorInstance<GithubCo
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (code: string) => {
const config = await this.getConfig(this.metadata.id);
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
this.validateConfig(config);
if (result.success) {
return result.data;
}
const { clientId: client_id, clientSecret: client_secret } = config;
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
const httpResponse = await got.post({
url: accessTokenEndpoint,
json: {
client_id,
client_secret,
code,
},
timeout: defaultTimeout,
});
if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body));
const { error, error_description, error_uri } = parsedError.data;
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
const { access_token: accessToken } = result.data;
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
errorDescription: error_description,
error_uri,
});
};
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
export const getAccessToken = async (config: GithubConfig, codeObject: { code: string }) => {
const { code } = codeObject;
const { clientId: client_id, clientSecret: client_secret } = config;
return { accessToken };
};
const httpResponse = await got.post({
url: accessTokenEndpoint,
json: {
client_id,
client_secret,
code,
},
timeout: defaultTimeout,
});
public getUserInfo: GetUserInfo = async (data) => {
const { code } = await this.authorizationCallbackHandler(data);
const { accessToken } = await this.getAccessToken(code);
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { access_token: accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<GithubConfig>(config, githubConfigGuard);
const { accessToken } = await getAccessToken(config, { code });
try {
const httpResponse = await got.get(userInfoEndpoint, {
@ -141,29 +142,13 @@ export default class GithubConnector implements SocialConnectorInstance<GithubCo
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeDataGuard.safeParse(parameterObject);
if (result.success) {
return result.data;
}
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
const { error, error_description, error_uri } = parsedError.data;
if (error === 'access_denied') {
throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, error_description);
}
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
errorDescription: error_description,
error_uri,
});
const createGithubConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: githubConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
}
};
export default createGithubConnector;

View file

@ -29,3 +29,5 @@ export const authorizationCallbackErrorGuard = z.object({
error_description: z.string(),
error_uri: z.string(),
});
export const authResponseGuard = z.object({ code: z.string() });

View file

@ -24,8 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
export const accessTokenEndpoint = 'https://oauth2.googleapis.com/token';

View file

@ -1,63 +1,21 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import GoogleConnector from '.';
import createConnector, { getAccessToken } from '.';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
import { mockedConfig } from './mock';
import { GoogleConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const googleMethods = new GoogleConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(googleMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('google connector', () => {
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<GoogleConfig> = googleMethods.validateConfig;
expect(() => {
validator({ clientId: 'clientId', clientSecret: 'clientSecret' });
}).not.toThrow();
});
it('should fail on invalid config', async () => {
const validator: ValidateConfig<GoogleConfig> = googleMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
expect(() => {
validator({ clientId: 'clientId' });
}).toThrow();
expect(() => {
validator({ clientSecret: 'clientSecret' });
}).toThrow();
});
});
describe('getAuthorizationUri', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get a valid authorizationUri with redirectUri and state', async () => {
const authorizationUri = await googleMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3000/callback',
});
@ -79,7 +37,10 @@ describe('google connector', () => {
scope: 'scope',
token_type: 'token_type',
});
const { accessToken } = await googleMethods.getAccessToken('code', 'dummyRedirectUri');
const { accessToken } = await getAccessToken(mockedConfig, {
code: 'code',
redirectUri: 'dummyRedirectUri',
});
expect(accessToken).toEqual('access_token');
});
@ -87,9 +48,9 @@ describe('google connector', () => {
nock(accessTokenEndpoint)
.post('')
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect(googleMethods.getAccessToken('code', 'dummyRedirectUri')).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
await expect(
getAccessToken(mockedConfig, { code: 'code', redirectUri: 'dummyRedirectUri' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
});
});
@ -118,7 +79,11 @@ describe('google connector', () => {
email_verified: true,
locale: 'en',
});
const socialUserInfo = await googleMethods.getUserInfo({ code: 'code', redirectUri: '' });
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({
code: 'code',
redirectUri: 'redirectUri',
});
expect(socialUserInfo).toMatchObject({
id: '1234567890',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -129,9 +94,10 @@ describe('google connector', () => {
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).post('').reply(401);
await expect(
googleMethods.getUserInfo({ code: 'code', redirectUri: '' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code', redirectUri: '' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});
it('throws General error', async () => {
@ -145,8 +111,9 @@ describe('google connector', () => {
email_verified: true,
locale: 'en',
});
const connector = await createConnector({ getConfig });
await expect(
googleMethods.getUserInfo({
connector.getUserInfo({
error: 'general_error',
error_description: 'General error encountered.',
})
@ -160,7 +127,8 @@ describe('google connector', () => {
it('throws unrecognized error', async () => {
nock(userInfoEndpoint).post('').reply(500);
await expect(googleMethods.getUserInfo({ code: 'code', redirectUri: '' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code', redirectUri: '' })).rejects.toThrow();
});
});
});

View file

@ -7,12 +7,11 @@ import {
ConnectorErrorCodes,
GetAuthorizationUri,
GetUserInfo,
ConnectorMetadata,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
validateConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { conditional, assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -29,38 +28,14 @@ import {
GoogleConfig,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
} from './types';
export default class GoogleConnector implements SocialConnectorInstance<GoogleConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is GoogleConfig {
const result = googleConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<GoogleConfig>(config, googleConfigGuard);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
@ -73,42 +48,46 @@ export default class GoogleConnector implements SocialConnectorInstance<GoogleCo
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (code: string, redirectUri: string) => {
const config = await this.getConfig(this.metadata.id);
export const getAccessToken = async (
config: GoogleConfig,
codeObject: { code: string; redirectUri: string }
) => {
const { code, redirectUri } = codeObject;
const { clientId, clientSecret } = config;
this.validateConfig(config);
// NoteNeed to decodeURIComponent on code
// https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code
const httpResponse = await got.post(accessTokenEndpoint, {
form: {
code: decodeURIComponent(code),
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
},
timeout: defaultTimeout,
});
const { clientId, clientSecret } = config;
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
// NoteNeed to decodeURIComponent on code
// https://stackoverflow.com/questions/51058256/google-api-node-js-invalid-grant-malformed-auth-code
const httpResponse = await got.post(accessTokenEndpoint, {
form: {
code: decodeURIComponent(code),
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
},
timeout: defaultTimeout,
});
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const { access_token: accessToken } = result.data;
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
const { access_token: accessToken } = result.data;
return { accessToken };
};
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = await this.authorizationCallbackHandler(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<GoogleConfig>(config, googleConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri });
try {
const httpResponse = await got.post(userInfoEndpoint, {
@ -133,31 +112,41 @@ export default class GoogleConnector implements SocialConnectorInstance<GoogleCo
name,
};
} catch (error: unknown) {
return this.getUserInfoErrorHandler(error);
return getUserInfoErrorHandler(error);
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeWithRedirectDataGuard.safeParse(parameterObject);
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
return result.data;
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
};
const createGoogleConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: googleConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
private readonly getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
};
}
export default createGoogleConnector;

View file

@ -1,4 +1,4 @@
import { z } from 'zod';
import { object, z } from 'zod';
export const googleConfigGuard = z.object({
clientId: z.string(),
@ -27,3 +27,8 @@ export const userInfoResponseGuard = z.object({
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export const authResponseGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});

View file

@ -22,7 +22,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"zod": "^3.14.3"

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const defaultMetadata: ConnectorMetadata = {
id: 'mock-email-service',

View file

@ -4,65 +4,23 @@ import path from 'path';
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
EmailSendMessageFunction,
EmailSendTestMessageFunction,
EmailConnectorInstance,
GetConnectorConfig,
EmailMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
CreateConnector,
EmailConnector,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import { defaultMetadata } from './constant';
import { mockMailConfigGuard, MockMailConfig } from './types';
export default class MockMailConnector implements EmailConnectorInstance<MockMailConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is MockMailConfig {
const result = mockMailConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
public sendTestMessage: EmailSendTestMessageFunction = async (config, address, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
private readonly sendMessageBy = async (
config: MockMailConfig,
address: string,
type: keyof EmailMessageTypes,
data: EmailMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<MockMailConfig>(config, mockMailConfigGuard);
const { templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -76,9 +34,18 @@ export default class MockMailConnector implements EmailConnectorInstance<MockMai
await fs.writeFile(
path.join('/tmp', 'logto_mock_passcode_record.txt'),
JSON.stringify({ address, code: data.code, type }) + '\n'
JSON.stringify({ address: to, code: payload.code, type }) + '\n'
);
return { address, data };
return { address: to, data: payload };
};
}
const createMockEmailConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: mockMailConfigGuard,
sendMessage: sendMessage(getConfig),
};
};
export default createMockEmailConnector;

View file

@ -22,7 +22,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"zod": "^3.14.3"

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const defaultMetadata: ConnectorMetadata = {
id: 'mock-short-message-service',

View file

@ -4,65 +4,23 @@ import path from 'path';
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
SmsSendMessageFunction,
SmsSendTestMessageFunction,
SmsConnectorInstance,
GetConnectorConfig,
SmsMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
validateConfig,
CreateConnector,
SmsConnector,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import { defaultMetadata } from './constant';
import { mockSmsConfigGuard, MockSmsConfig } from './types';
export default class MockSmsConnector implements SmsConnectorInstance<MockSmsConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is MockSmsConfig {
const result = mockSmsConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: SmsSendMessageFunction = async (phone, type, data) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
return this.sendMessageBy(config, phone, type, data);
};
public sendTestMessage: SmsSendTestMessageFunction = async (config, phone, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, phone, type, data);
};
private readonly sendMessageBy = async (
config: MockSmsConfig,
phone: string,
type: keyof SmsMessageTypes,
data: SmsMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<MockSmsConfig>(config, mockSmsConfigGuard);
const { templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -76,9 +34,18 @@ export default class MockSmsConnector implements SmsConnectorInstance<MockSmsCon
await fs.writeFile(
path.join('/tmp', 'logto_mock_passcode_record.txt'),
JSON.stringify({ phone, code: data.code, type }) + '\n'
JSON.stringify({ phone: to, code: payload.code, type }) + '\n'
);
return { phone, data };
return { phone: to, data: payload };
};
}
const createMockSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: mockSmsConfigGuard,
sendMessage: sendMessage(getConfig),
};
};
export default createMockSmsConnector;

View file

@ -22,7 +22,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const defaultMetadata: ConnectorMetadata = {
id: 'mock-social-connector',

View file

@ -5,59 +5,41 @@ import {
ConnectorErrorCodes,
GetAuthorizationUri,
GetUserInfo,
ConnectorMetadata,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
} from '@logto/connector-types';
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { z } from 'zod';
import { defaultMetadata } from './constant';
import { mockSocialConfigGuard, MockSocialConfig } from './types';
import { mockSocialConfigGuard } from './types';
export default class MockSocialConnector implements SocialConnectorInstance<MockSocialConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
const getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`;
};
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
const getAccessToken = async () => randomUUID();
return this._connector;
const getUserInfo: GetUserInfo = async (data) => {
const dataGuard = z.object({ code: z.string(), userId: z.optional(z.string()) });
const result = dataGuard.safeParse(data);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data));
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is MockSocialConfig {
const result = mockSocialConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
return `http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`;
// For mock use only. Use to track the created user entity
return {
id: result.data.userId ?? `mock-social-sub-${randomUUID()}`,
};
};
public getAccessToken = async () => randomUUID();
public getUserInfo: GetUserInfo = async (data) => {
const dataGuard = z.object({ code: z.string(), userId: z.optional(z.string()) });
const result = dataGuard.safeParse(data);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, JSON.stringify(data));
}
// For mock use only. Use to track the created user entity
return {
id: result.data.userId ?? `mock-social-sub-${randomUUID()}`,
};
const createMockSocialConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: mockSocialConfigGuard,
getAuthorizationUri,
getUserInfo,
};
}
};
export default createMockSocialConnector;

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const endpoint = 'https://api.sendgrid.com/v3/mail/send';

View file

@ -1,52 +1,10 @@
import { GetConnectorConfig, ValidateConfig } from '@logto/connector-types';
import SendGridMailConnector from '.';
import createConnector from '.';
import { mockedConfig } from './mock';
import { ContextType, SendGridMailConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
const sendGridMailMethods = new SendGridMailConnector(getConnectorConfig);
jest.mock('got');
beforeAll(() => {
jest.spyOn(sendGridMailMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<SendGridMailConfig> = sendGridMailMethods.validateConfig;
expect(() => {
validator({
apiKey: 'apiKey',
fromEmail: 'noreply@logto.test.io',
fromName: 'Logto Test',
templates: [
{
usageType: 'Test',
type: ContextType.Text,
subject: 'Logto Test Template',
content: 'This is for testing purposes only. Your passcode is {{code}}.',
},
],
});
}).not.toThrow();
});
it('should be false if config is invalid', async () => {
const validator: ValidateConfig<SendGridMailConfig> = sendGridMailMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
describe('SendGrid connector', () => {
it('init without throwing errors', async () => {
await expect(createConnector({ getConfig })).resolves.not.toThrow();
});
});

View file

@ -1,14 +1,12 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
EmailSendMessageFunction,
EmailSendTestMessageFunction,
EmailConnectorInstance,
GetConnectorConfig,
EmailMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
validateConfig,
CreateConnector,
EmailConnector,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -22,52 +20,13 @@ import {
PublicParameters,
} from './types';
export default class SendGridMailConnector implements EmailConnectorInstance<SendGridMailConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is SendGridMailConfig {
const result = sendGridMailConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
public sendTestMessage: EmailSendTestMessageFunction = async (config, address, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
private readonly sendMessageBy = async (
config: SendGridMailConfig,
address: string,
type: keyof EmailMessageTypes,
data: EmailMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
// eslint-disable-next-line complexity
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<SendGridMailConfig>(config, sendGridMailConfigGuard);
const { apiKey, fromEmail, fromName, templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -79,7 +38,7 @@ export default class SendGridMailConnector implements EmailConnectorInstance<Sen
)
);
const toEmailData: EmailData[] = [{ email: address }];
const toEmailData: EmailData[] = [{ email: to }];
const fromEmailData: EmailData = fromName
? { email: fromEmail, name: fromName }
: { email: fromEmail };
@ -87,8 +46,8 @@ export default class SendGridMailConnector implements EmailConnectorInstance<Sen
const content: Content = {
type: template.type,
value:
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
typeof payload.code === 'string'
? template.content.replace(/{{code}}/g, payload.code)
: template.content,
};
const { subject } = template;
@ -124,4 +83,13 @@ export default class SendGridMailConnector implements EmailConnectorInstance<Sen
throw error;
}
};
}
const createSendGridMailConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: sendGridMailConfigGuard,
sendMessage: sendMessage(getConfig),
};
};
export default createSendGridMailConnector;

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const defaultMetadata: ConnectorMetadata = {
id: 'simple-mail-transfer-protocol',

View file

@ -1,65 +1,10 @@
import { GetConnectorConfig, ValidateConfig } from '@logto/connector-types';
import createConnector from '.';
import { mockedConfig } from './mock';
import SmtpConnector from '.';
import { SmtpConfig } from './types';
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const smtpMethods = new SmtpConnector(getConnectorConfig);
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<SmtpConfig> = smtpMethods.validateConfig;
expect(() => {
validator({
host: 'smtp.testing.com',
port: 80,
password: 'password',
username: 'username',
fromEmail: 'test@smtp.testing.com',
templates: [
{
contentType: 'text/plain',
content: 'This is for testing purposes only.',
subject: 'Logto Test with SMTP',
usageType: 'Test',
},
{
contentType: 'text/plain',
content: 'This is for sign-in purposes only.',
subject: 'Logto Sign In with SMTP',
usageType: 'SignIn',
},
{
contentType: 'text/plain',
content: 'This is for register purposes only.',
subject: 'Logto Register with SMTP',
usageType: 'Register',
},
{
contentType: 'text/plain',
content: 'This is for forgot-password purposes only.',
subject: 'Logto Forgot Password with SMTP',
usageType: 'ForgotPassword',
},
],
});
}).not.toThrow();
});
it('should be false if config is invalid', async () => {
const validator: ValidateConfig<SmtpConfig> = smtpMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
describe('SMTP connector', () => {
it('init without throwing errors', async () => {
await expect(createConnector({ getConfig })).resolves.not.toThrow();
});
});

View file

@ -1,14 +1,12 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
EmailSendMessageFunction,
EmailSendTestMessageFunction,
EmailConnectorInstance,
GetConnectorConfig,
EmailMessageTypes,
} from '@logto/connector-types';
CreateConnector,
EmailConnector,
SendMessageFunction,
validateConfig,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import nodemailer from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
@ -16,52 +14,12 @@ import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { defaultMetadata } from './constant';
import { ContextType, smtpConfigGuard, SmtpConfig } from './types';
export default class SmtpConnector implements EmailConnectorInstance<SmtpConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is SmtpConfig {
const result = smtpConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
public sendTestMessage: EmailSendTestMessageFunction = async (config, address, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, address, type, data);
};
private readonly sendMessageBy = async (
config: SmtpConfig,
address: string,
type: keyof EmailMessageTypes,
data: EmailMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<SmtpConfig>(config, smtpConfigGuard);
const { host, port, username, password, fromEmail, replyTo, templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -89,15 +47,15 @@ export default class SmtpConnector implements EmailConnectorInstance<SmtpConfig>
const transporter = nodemailer.createTransport(configOptions);
const contentsObject = this.parseContents(
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
const contentsObject = parseContents(
typeof payload.code === 'string'
? template.content.replace(/{{code}}/g, payload.code)
: template.content,
template.contentType
);
const mailOptions = {
to: address,
to,
from: fromEmail,
replyTo,
subject: template.subject,
@ -114,17 +72,26 @@ export default class SmtpConnector implements EmailConnectorInstance<SmtpConfig>
}
};
private readonly parseContents = (contents: string, contentType: ContextType) => {
switch (contentType) {
case ContextType.Text:
return { text: contents };
case ContextType.Html:
return { html: contents };
default:
throw new ConnectorError(
ConnectorErrorCodes.InvalidConfig,
'`contentType` should be ContextType.'
);
}
const parseContents = (contents: string, contentType: ContextType) => {
switch (contentType) {
case ContextType.Text:
return { text: contents };
case ContextType.Html:
return { html: contents };
default:
throw new ConnectorError(
ConnectorErrorCodes.InvalidConfig,
'`contentType` should be ContextType.'
);
}
};
const createSmtpConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: smtpConfigGuard,
sendMessage: sendMessage(getConfig),
};
}
};
export default createSmtpConnector;

View file

@ -0,0 +1,15 @@
export const mockedConfig = {
host: '<test.smtp.host>',
port: 80,
password: '<password>',
username: '<username>',
fromEmail: '<notice@test.smtp>',
templates: [
{
contentType: 'text/plain',
content: 'This is for testing purposes only. Your verification code is {{code}}.',
subject: 'Logto Test with SMTP',
usageType: 'Test',
},
],
};

View file

@ -24,7 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorType, ConnectorMetadata } from '@logto/connector-types';
import { ConnectorType, ConnectorMetadata } from '@logto/connector-core';
export const endpoint = 'https://api.twilio.com/2010-04-01/Accounts/{{accountSID}}/Messages.json';

View file

@ -1,34 +1,10 @@
import { GetConnectorConfig, ValidateConfig } from '@logto/connector-types';
import TwilioSmsConnector from '.';
import createConnector from '.';
import { mockedConfig } from './mock';
import { TwilioSmsConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
const twilioSmsMethods = new TwilioSmsConnector(getConnectorConfig);
describe('validateConfig()', () => {
afterEach(() => {
jest.clearAllMocks();
});
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<TwilioSmsConfig> = twilioSmsMethods.validateConfig;
expect(() => {
validator(mockedConfig);
}).not.toThrow();
});
it('throws if config is invalid', async () => {
const validator: ValidateConfig<TwilioSmsConfig> = twilioSmsMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
describe('Twilio SMS connector', () => {
it('init without throwing errors', async () => {
await expect(createConnector({ getConfig })).resolves.not.toThrow();
});
});

View file

@ -1,66 +1,24 @@
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorMetadata,
Connector,
SmsSendMessageFunction,
SmsSendTestMessageFunction,
SmsConnectorInstance,
GetConnectorConfig,
SmsMessageTypes,
} from '@logto/connector-types';
SendMessageFunction,
validateConfig,
CreateConnector,
SmsConnector,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
import { defaultMetadata, endpoint } from './constant';
import { twilioSmsConfigGuard, TwilioSmsConfig, PublicParameters } from './types';
export default class TwilioSmsConnector implements SmsConnectorInstance<TwilioSmsConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is TwilioSmsConfig {
const result = twilioSmsConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public sendMessage: SmsSendMessageFunction = async (phone, type, data) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
return this.sendMessageBy(config, phone, type, data);
};
public sendTestMessage: SmsSendTestMessageFunction = async (config, phone, type, data) => {
this.validateConfig(config);
return this.sendMessageBy(config, phone, type, data);
};
private readonly sendMessageBy = async (
config: TwilioSmsConfig,
phone: string,
type: keyof SmsMessageTypes,
data: SmsMessageTypes[typeof type]
) => {
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig<TwilioSmsConfig>(config, twilioSmsConfigGuard);
const { accountSID, authToken, fromMessagingServiceSID, templates } = config;
const template = templates.find((template) => template.usageType === type);
@ -73,11 +31,11 @@ export default class TwilioSmsConnector implements SmsConnectorInstance<TwilioSm
);
const parameters: PublicParameters = {
To: phone,
To: to,
MessagingServiceSid: fromMessagingServiceSID,
Body:
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
typeof payload.code === 'string'
? template.content.replace(/{{code}}/g, payload.code)
: template.content,
};
@ -106,4 +64,13 @@ export default class TwilioSmsConnector implements SmsConnectorInstance<TwilioSm
throw error;
}
};
}
const createTwilioSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: twilioSmsConfigGuard,
sendMessage: sendMessage(getConfig),
};
};
export default createTwilioSmsConnector;

View file

@ -1,156 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.0.0-beta.5](https://github.com/logto-io/logto/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2022-08-19)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-beta.4](https://github.com/logto-io/logto/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2022-08-11)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-beta.3](https://github.com/logto-io/logto/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2022-08-01)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-beta.2](https://github.com/logto-io/logto/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2022-07-25)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-beta.1](https://github.com/logto-io/logto/compare/v1.0.0-beta.0...v1.0.0-beta.1) (2022-07-19)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-beta.0](https://github.com/logto-io/logto/compare/v1.0.0-alpha.4...v1.0.0-beta.0) (2022-07-14)
### Bug Fixes
* **connector:** fix connector getConfig and validateConfig type ([#1530](https://github.com/logto-io/logto/issues/1530)) ([88a54aa](https://github.com/logto-io/logto/commit/88a54aaa9ebce419c149a33150a4927296cb705b))
* **connector:** passwordless connector send test msg with unsaved config ([#1539](https://github.com/logto-io/logto/issues/1539)) ([0297f6c](https://github.com/logto-io/logto/commit/0297f6c52f7b5d730de44fbb08f88c2e9b951874))
* **connector:** refactor ConnectorInstance as class ([#1541](https://github.com/logto-io/logto/issues/1541)) ([6b9ad58](https://github.com/logto-io/logto/commit/6b9ad580ae86fbcc100a100aab1d834090e682a3))
## [1.0.0-alpha.4](https://github.com/logto-io/logto/compare/v1.0.0-alpha.3...v1.0.0-alpha.4) (2022-07-08)
### Features
* expose zod error ([#1474](https://github.com/logto-io/logto/issues/1474)) ([81b63f0](https://github.com/logto-io/logto/commit/81b63f07bb412abf1f2b42059bac2ffcfc86272c))
## [1.0.0-alpha.3](https://github.com/logto-io/logto/compare/v1.0.0-alpha.2...v1.0.0-alpha.3) (2022-07-07)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-alpha.2](https://github.com/logto-io/logto/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2022-07-07)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-alpha.1](https://github.com/logto-io/logto/compare/v1.0.0-alpha.0...v1.0.0-alpha.1) (2022-07-05)
**Note:** Version bump only for package @logto/connector-types
## [1.0.0-alpha.0](https://github.com/logto-io/logto/compare/v0.1.2-alpha.5...v1.0.0-alpha.0) (2022-07-04)
**Note:** Version bump only for package @logto/connector-types
### [0.1.2-alpha.5](https://github.com/logto-io/logto/compare/v0.1.2-alpha.4...v0.1.2-alpha.5) (2022-07-03)
**Note:** Version bump only for package @logto/connector-types
### [0.1.2-alpha.4](https://github.com/logto-io/logto/compare/v0.1.2-alpha.3...v0.1.2-alpha.4) (2022-07-03)
**Note:** Version bump only for package @logto/connector-types
### [0.1.2-alpha.3](https://github.com/logto-io/logto/compare/v0.1.2-alpha.2...v0.1.2-alpha.3) (2022-07-03)
**Note:** Version bump only for package @logto/connector-types
### [0.1.2-alpha.2](https://github.com/logto-io/logto/compare/v0.1.2-alpha.1...v0.1.2-alpha.2) (2022-07-02)
**Note:** Version bump only for package @logto/connector-types
### [0.1.2-alpha.1](https://github.com/logto-io/logto/compare/v0.1.2-alpha.0...v0.1.2-alpha.1) (2022-07-02)
**Note:** Version bump only for package @logto/connector-types
### [0.1.1-alpha.0](https://github.com/logto-io/logto/compare/v0.1.0-internal...v0.1.1-alpha.0) (2022-07-01)
### Features
* **connector-sendgrid-email:** add sendgrid email connector ([#850](https://github.com/logto-io/logto/issues/850)) ([b887655](https://github.com/logto-io/logto/commit/b8876558275e28ca921d4eeea6c38f8559810a11))
* **connector:** apple ([#966](https://github.com/logto-io/logto/issues/966)) ([7400ed8](https://github.com/logto-io/logto/commit/7400ed8896fdceda6165a0540413efb4e3a47438))
* **connectors:** handle authorization callback parameters in each connector respectively ([#1166](https://github.com/logto-io/logto/issues/1166)) ([097aade](https://github.com/logto-io/logto/commit/097aade2e2e1b1ea1531bcb4c1cca8d24961a9b9))
* **core,connectors:** update Aliyun logo and add logo_dark to Apple, Github ([#1194](https://github.com/logto-io/logto/issues/1194)) ([98f8083](https://github.com/logto-io/logto/commit/98f808320b1c79c51f8bd6f49e35ca44363ea560))
* **core:** update connector db schema ([#732](https://github.com/logto-io/logto/issues/732)) ([8e1533a](https://github.com/logto-io/logto/commit/8e1533a70267d459feea4e5174296b17bef84d48))
* **core:** wrap aliyun direct mail connector ([#660](https://github.com/logto-io/logto/issues/660)) ([54b6209](https://github.com/logto-io/logto/commit/54b62094c8d8af0611cf64e39306c4f1a216e8f6))
* **core:** wrap aliyun short message service connector ([#670](https://github.com/logto-io/logto/issues/670)) ([a06d3ee](https://github.com/logto-io/logto/commit/a06d3ee73ccc59f6aaef1dab4f45d6c118aab40d))
* **native-connectors:** pass random state to native connector sdk ([#922](https://github.com/logto-io/logto/issues/922)) ([9679620](https://github.com/logto-io/logto/commit/96796203dd4247d7ecdee044f13f3d57f04ca461))
* remove target, platform from connector schema and add id to metadata ([#930](https://github.com/logto-io/logto/issues/930)) ([054b0f7](https://github.com/logto-io/logto/commit/054b0f7b6a6dfed66540042ea69b0721126fe695))
### Bug Fixes
* `lint:report` script ([#730](https://github.com/logto-io/logto/issues/730)) ([3b17324](https://github.com/logto-io/logto/commit/3b17324d189b2fe47985d0bee8b37b4ef1dbdd2b))

View file

@ -1,183 +0,0 @@
// FIXME: @Darcy
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { Language } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
import { z } from 'zod';
/**
* Connector is auto-generated in @logto/schemas according to sql file.
* As @logto/schemas depends on this repo (@logto/connector-types), we manually define Connector type again as a temporary solution.
*/
export const arbitraryObjectGuard = z.union([z.object({}).catchall(z.unknown()), z.object({})]);
export type ArbitraryObject = z.infer<typeof arbitraryObjectGuard>;
export type Connector = {
id: string;
enabled: boolean;
config: ArbitraryObject;
createdAt: number;
};
export enum ConnectorType {
Email = 'Email',
SMS = 'SMS',
Social = 'Social',
}
export enum ConnectorPlatform {
Native = 'Native',
Universal = 'Universal',
Web = 'Web',
}
type i18nPhrases = { [Language.English]: string } & {
[key in Exclude<Language, Language.English>]?: string;
};
export interface ConnectorMetadata {
id: string;
target: string;
type: ConnectorType;
platform: Nullable<ConnectorPlatform>;
name: i18nPhrases;
logo: string;
logoDark: Nullable<string>;
description: i18nPhrases;
readme: string;
configTemplate: string;
}
export enum ConnectorErrorCodes {
General,
InsufficientRequestParameters,
InvalidConfig,
InvalidResponse,
TemplateNotFound,
NotImplemented,
SocialAuthCodeInvalid,
SocialAccessTokenInvalid,
SocialIdTokenInvalid,
AuthorizationFailed,
}
export class ConnectorError extends Error {
public code: ConnectorErrorCodes;
public data: unknown;
constructor(code: ConnectorErrorCodes, data?: unknown) {
const message = typeof data === 'string' ? data : 'Connector error occurred.';
super(message);
this.code = code;
this.data = typeof data === 'string' ? { message: data } : data;
}
}
export type EmailMessageTypes = {
SignIn: {
code: string;
};
Register: {
code: string;
};
ForgotPassword: {
code: string;
};
Test: Record<string, unknown>;
};
export type SmsMessageTypes = EmailMessageTypes;
export type EmailSendMessageFunction<T = unknown> = (
address: string,
type: keyof EmailMessageTypes,
payload: EmailMessageTypes[typeof type]
) => Promise<T>;
export type EmailSendTestMessageFunction<T = unknown> = (
config: Record<string, unknown>,
address: string,
type: keyof EmailMessageTypes,
payload: EmailMessageTypes[typeof type]
) => Promise<T>;
export type SmsSendMessageFunction<T = unknown> = (
phone: string,
type: keyof SmsMessageTypes,
payload: SmsMessageTypes[typeof type]
) => Promise<T>;
export type SmsSendTestMessageFunction<T = unknown> = (
config: Record<string, unknown>,
phone: string,
type: keyof SmsMessageTypes,
payload: SmsMessageTypes[typeof type]
) => Promise<T>;
export interface BaseConnector<T = unknown> {
metadata: ConnectorMetadata;
getConfig: GetConnectorConfig;
validateConfig: ValidateConfig<T>;
}
export interface SmsConnector<T = unknown> extends BaseConnector<T> {
sendMessage: SmsSendMessageFunction;
sendTestMessage?: SmsSendTestMessageFunction;
}
export interface SmsConnectorInstance<T = unknown> extends SmsConnector<T> {
connector: Connector;
}
export interface EmailConnector<T = unknown> extends BaseConnector<T> {
sendMessage: EmailSendMessageFunction;
sendTestMessage?: EmailSendTestMessageFunction;
}
export interface EmailConnectorInstance<T = unknown> extends EmailConnector<T> {
connector: Connector;
}
export interface SocialConnector<T = unknown> extends BaseConnector<T> {
getAuthorizationUri: GetAuthorizationUri;
getUserInfo: GetUserInfo;
}
export interface SocialConnectorInstance<T = unknown> extends SocialConnector<T> {
connector: Connector;
}
export type ConnectorInstance =
| SmsConnectorInstance
| EmailConnectorInstance
| SocialConnectorInstance;
export type ValidateConfig<T = unknown> = (config: unknown) => asserts config is T;
export type GetAuthorizationUri = (payload: {
state: string;
redirectUri: string;
}) => Promise<string>;
export type GetUserInfo = (
data: unknown
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig = (id: string) => Promise<unknown>;
export const codeDataGuard = z.object({
code: z.string(),
});
export type CodeData = z.infer<typeof codeDataGuard>;
export const codeWithRedirectDataGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});
export type CodeWithRedirectData = z.infer<typeof codeWithRedirectDataGuard>;
/* eslint-enable @typescript-eslint/consistent-type-definitions */

View file

@ -1,5 +0,0 @@
{
"extends": "./tsconfig",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -24,8 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'wechat://'; // This is used to arouse the native WeChat App
export const accessTokenEndpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token';

View file

@ -1,23 +1,11 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import WechatNativeConnector from '.';
import createConnector, { getAccessToken } from '.';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
import { mockedConfig } from './mock';
import { WechatNativeConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const wechatNativeMethods = new WechatNativeConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(wechatNativeMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('getAuthorizationUri', () => {
afterEach(() => {
@ -25,7 +13,8 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri', async () => {
const authorizationUri = await wechatNativeMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'dummy-state',
redirectUri: 'dummy-redirect-uri',
});
@ -57,7 +46,7 @@ describe('getAccessToken', () => {
access_token: 'access_token',
openid: 'openid',
});
const { accessToken, openid } = await wechatNativeMethods.getAccessToken('code');
const { accessToken, openid } = await getAccessToken('code', mockedConfig);
expect(accessToken).toEqual('access_token');
expect(openid).toEqual('openid');
});
@ -67,7 +56,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' });
await expect(wechatNativeMethods.getAccessToken('code')).rejects.toMatchError(
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code')
);
});
@ -77,7 +66,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: 40_163, errmsg: 'code been used' });
await expect(wechatNativeMethods.getAccessToken('code')).rejects.toMatchError(
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used')
);
});
@ -87,7 +76,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: -1, errmsg: 'system error' });
await expect(wechatNativeMethods.getAccessToken('wrong_code')).rejects.toMatchError(
await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error',
errcode: -1,
@ -96,34 +85,6 @@ describe('getAccessToken', () => {
});
});
describe('validateConfig', () => {
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<WechatNativeConfig> = wechatNativeMethods.validateConfig;
expect(() => {
validator({ appId: 'appId', appSecret: 'appSecret' });
}).not.toThrow();
});
it('should fail on empty config', async () => {
const validator: ValidateConfig<WechatNativeConfig> = wechatNativeMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
it('should fail when missing appSecret', async () => {
const validator: ValidateConfig<WechatNativeConfig> = wechatNativeMethods.validateConfig;
expect(() => {
validator({ appId: 'appId' });
}).toThrow();
});
});
const nockNoOpenIdAccessTokenResponse = () => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
const parameters = new URLSearchParams({
@ -173,7 +134,8 @@ describe('getUserInfo', () => {
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
const socialUserInfo = await wechatNativeMethods.getUserInfo({ code: 'code' });
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' });
expect(socialUserInfo).toMatchObject({
id: 'this_is_an_arbitrary_wechat_union_id',
avatar: 'https://github.com/images/error/octocat_happy.gif',
@ -182,7 +144,8 @@ describe('getUserInfo', () => {
});
it('throws General error if code not provided in input', async () => {
await expect(wechatNativeMethods.getUserInfo({})).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({})).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
});
@ -196,7 +159,8 @@ describe('getUserInfo', () => {
errcode: 41_009,
errmsg: 'missing openid',
});
await expect(wechatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid',
errcode: 41_009,
@ -209,14 +173,16 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
await expect(wechatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential')
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
await expect(wechatNativeMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toThrow();
});
it('throws Error if request failed and errcode is not 40001', async () => {
@ -224,7 +190,8 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_003, errmsg: 'invalid openid' });
await expect(wechatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid',
errcode: 40_003,
@ -234,7 +201,8 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
await expect(wechatNativeMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});

View file

@ -4,16 +4,15 @@
*/
import {
ConnectorMetadata,
GetAuthorizationUri,
GetUserInfo,
ConnectorError,
ConnectorErrorCodes,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
validateConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -33,38 +32,15 @@ import {
userInfoResponseGuard,
UserInfoResponseMessageParser,
WechatNativeConfig,
authResponseGuard,
} from './types';
export default class WechatNativeConnector implements SocialConnectorInstance<WechatNativeConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state }) => {
const config = await getConfig(defaultMetadata.id);
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is WechatNativeConfig {
const result = wechatNativeConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
validateConfig<WechatNativeConfig>(config, wechatNativeConfigGuard);
const { appId, universalLinks } = config;
@ -79,37 +55,38 @@ export default class WechatNativeConnector implements SocialConnectorInstance<We
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (
code: string
): Promise<{ accessToken: string; openid: string }> => {
const config = await this.getConfig(this.metadata.id);
export const getAccessToken = async (
code: string,
config: WechatNativeConfig
): Promise<{ accessToken: string; openid: string }> => {
const { appId: appid, appSecret: secret } = config;
this.validateConfig(config);
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
timeout: defaultTimeout,
});
const { appId: appid, appSecret: secret } = config;
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
timeout: defaultTimeout,
});
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const { access_token: accessToken, openid } = result.data;
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
getAccessTokenErrorHandler(result.data);
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
const { access_token: accessToken, openid } = result.data;
return { accessToken, openid };
};
this.getAccessTokenErrorHandler(result.data);
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
return { accessToken, openid };
};
public getUserInfo: GetUserInfo = async (data) => {
const { code } = await this.authorizationCallbackHandler(data);
const { accessToken, openid } = await this.getAccessToken(code);
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<WechatNativeConfig>(config, wechatNativeConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config);
try {
const httpResponse = await got.get(userInfoEndpoint, {
@ -128,62 +105,72 @@ export default class WechatNativeConnector implements SocialConnectorInstance<We
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
this.userInfoResponseMessageParser(result.data);
userInfoResponseMessageParser(result.data);
return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) {
return this.getUserInfoErrorHandler(error);
return getUserInfoErrorHandler(error);
}
};
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
private readonly getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
);
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
private readonly userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
);
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
private readonly getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw error;
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
};
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createWechatNativeConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: wechatNativeConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeDataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
}
export default createWechatNativeConnector;

View file

@ -33,3 +33,5 @@ export const userInfoResponseGuard = z.object({
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() });

View file

@ -24,8 +24,7 @@
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/schemas": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/shared": "^1.0.0-beta.5",
"@silverhand/essentials": "^1.2.0",
"@silverhand/jest-config": "1.0.0-rc.3",

View file

@ -1,4 +1,4 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-core';
export const authorizationEndpoint = 'https://open.weixin.qq.com/connect/qrconnect';
export const accessTokenEndpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token';

View file

@ -1,23 +1,11 @@
import {
ConnectorError,
ConnectorErrorCodes,
GetConnectorConfig,
ValidateConfig,
} from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import nock from 'nock';
import WechatConnector from '.';
import createConnector, { getAccessToken } from '.';
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant';
import { mockedConfig } from './mock';
import { WechatConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
const wechatMethods = new WechatConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(wechatMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
const getConfig = jest.fn().mockResolvedValue(mockedConfig);
describe('getAuthorizationUri', () => {
afterEach(() => {
@ -25,7 +13,8 @@ describe('getAuthorizationUri', () => {
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await wechatMethods.getAuthorizationUri({
const connector = await createConnector({ getConfig });
const authorizationUri = await connector.getAuthorizationUri({
state: 'some_state',
redirectUri: 'http://localhost:3001/callback',
});
@ -57,7 +46,7 @@ describe('getAccessToken', () => {
access_token: 'access_token',
openid: 'openid',
});
const { accessToken, openid } = await wechatMethods.getAccessToken('code');
const { accessToken, openid } = await getAccessToken('code', mockedConfig);
expect(accessToken).toEqual('access_token');
expect(openid).toEqual('openid');
});
@ -67,7 +56,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_029, errmsg: 'invalid code' });
await expect(wechatMethods.getAccessToken('code')).rejects.toMatchError(
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code')
);
});
@ -77,7 +66,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: 40_163, errmsg: 'code been used' });
await expect(wechatMethods.getAccessToken('code')).rejects.toMatchError(
await expect(getAccessToken('code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used')
);
});
@ -87,7 +76,7 @@ describe('getAccessToken', () => {
.get(accessTokenEndpointUrl.pathname)
.query(true)
.reply(200, { errcode: -1, errmsg: 'system error' });
await expect(wechatMethods.getAccessToken('wrong_code')).rejects.toMatchError(
await expect(getAccessToken('wrong_code', mockedConfig)).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'system error',
errcode: -1,
@ -96,34 +85,6 @@ describe('getAccessToken', () => {
});
});
describe('validateConfig', () => {
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
it('should pass on valid config', async () => {
const validator: ValidateConfig<WechatConfig> = wechatMethods.validateConfig;
expect(() => {
validator({ appId: 'appId', appSecret: 'appSecret' });
}).not.toThrow();
});
it('should fail on empty config', async () => {
const validator: ValidateConfig<WechatConfig> = wechatMethods.validateConfig;
expect(() => {
validator({});
}).toThrow();
});
it('should fail when missing appSecret', async () => {
const validator: ValidateConfig<WechatConfig> = wechatMethods.validateConfig;
expect(() => {
validator({ appId: 'appId' });
}).toThrow();
});
});
const nockNoOpenIdAccessTokenResponse = () => {
const accessTokenEndpointUrl = new URL(accessTokenEndpoint);
nock(accessTokenEndpointUrl.origin).get(accessTokenEndpointUrl.pathname).query(true).reply(200, {
@ -164,7 +125,8 @@ describe('getUserInfo', () => {
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
const socialUserInfo = await wechatMethods.getUserInfo({
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({
code: 'code',
});
expect(socialUserInfo).toMatchObject({
@ -175,7 +137,8 @@ describe('getUserInfo', () => {
});
it('throws General error if code not provided in input', async () => {
await expect(wechatMethods.getUserInfo({})).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({})).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, '{}')
);
});
@ -189,7 +152,8 @@ describe('getUserInfo', () => {
errcode: 41_009,
errmsg: 'missing openid',
});
await expect(wechatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'missing openid',
errcode: 41_009,
@ -202,14 +166,16 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_001, errmsg: 'invalid credential' });
await expect(wechatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential')
);
});
it('throws unrecognized error', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500);
await expect(wechatMethods.getUserInfo({ code: 'code' })).rejects.toThrow();
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toThrow();
});
it('throws Error if request failed and errcode is not 40001', async () => {
@ -217,7 +183,8 @@ describe('getUserInfo', () => {
.get(userInfoEndpointUrl.pathname)
.query(parameters)
.reply(200, { errcode: 40_003, errmsg: 'invalid openid' });
await expect(wechatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.General, {
errorDescription: 'invalid openid',
errcode: 40_003,
@ -227,7 +194,8 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401);
await expect(wechatMethods.getUserInfo({ code: 'code' })).rejects.toMatchError(
const connector = await createConnector({ getConfig });
await expect(connector.getUserInfo({ code: 'code' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
});

View file

@ -4,16 +4,15 @@
*/
import {
ConnectorMetadata,
GetAuthorizationUri,
GetUserInfo,
ConnectorError,
ConnectorErrorCodes,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
codeDataGuard,
} from '@logto/connector-types';
validateConfig,
CreateConnector,
SocialConnector,
} from '@logto/connector-core';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
@ -34,38 +33,14 @@ import {
userInfoResponseGuard,
UserInfoResponseMessageParser,
WechatConfig,
authResponseGuard,
} from './types';
export default class WechatConnector implements SocialConnectorInstance<WechatConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is WechatConfig {
const result = wechatConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig<WechatConfig>(config, wechatConfigGuard);
const { appId } = config;
@ -80,38 +55,39 @@ export default class WechatConnector implements SocialConnectorInstance<WechatCo
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
public getAccessToken = async (
code: string
): Promise<{ accessToken: string; openid: string }> => {
const config = await this.getConfig(this.metadata.id);
export const getAccessToken = async (
code: string,
config: WechatConfig
): Promise<{ accessToken: string; openid: string }> => {
const { appId: appid, appSecret: secret } = config;
this.validateConfig(config);
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
timeout: defaultTimeout,
});
const { appId: appid, appSecret: secret } = config;
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const httpResponse = await got.get(accessTokenEndpoint, {
searchParams: { appid, secret, code, grant_type: 'authorization_code' },
timeout: defaultTimeout,
});
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
const { access_token: accessToken, openid } = result.data;
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
getAccessTokenErrorHandler(result.data);
const { access_token: accessToken, openid } = result.data;
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
this.getAccessTokenErrorHandler(result.data);
return { accessToken, openid };
};
assert(accessToken && openid, new ConnectorError(ConnectorErrorCodes.InvalidResponse));
return { accessToken, openid };
};
public getUserInfo: GetUserInfo = async (data) => {
const { code } = await this.authorizationCallbackHandler(data);
const { accessToken, openid } = await this.getAccessToken(code);
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig<WechatConfig>(config, wechatConfigGuard);
const { accessToken, openid } = await getAccessToken(code, config);
try {
const httpResponse = await got.get(userInfoEndpoint, {
@ -130,62 +106,72 @@ export default class WechatConnector implements SocialConnectorInstance<WechatCo
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
this.userInfoResponseMessageParser(result.data);
userInfoResponseMessageParser(result.data);
return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) {
return this.getUserInfoErrorHandler(error);
return getUserInfoErrorHandler(error);
}
};
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
private readonly getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html
const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => {
const { errcode, errmsg } = accessToken;
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
);
if (errcode) {
assert(
!invalidAuthCodeErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
private readonly userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
const userInfoResponseMessageParser: UserInfoResponseMessageParser = (userInfo) => {
const { errcode, errmsg } = userInfo;
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
);
if (errcode) {
assert(
!invalidAccessTokenErrcode.includes(errcode),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg)
);
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode });
}
};
private readonly getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const getUserInfoErrorHandler = (error: unknown) => {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw error;
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
};
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
const createWechatConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
configGuard: wechatConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeDataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
}
export default createWechatConnector;

View file

@ -29,3 +29,5 @@ export const userInfoResponseGuard = z.object({
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() });

View file

@ -25,13 +25,13 @@
"@logto/connector-aliyun-sms": "^1.0.0-beta.5",
"@logto/connector-apple": "^1.0.0-beta.5",
"@logto/connector-azuread": "^1.0.0-beta.5",
"@logto/connector-core": "^1.0.0-beta.5",
"@logto/connector-facebook": "^1.0.0-beta.5",
"@logto/connector-github": "^1.0.0-beta.5",
"@logto/connector-google": "^1.0.0-beta.5",
"@logto/connector-sendgrid-email": "^1.0.0-beta.5",
"@logto/connector-smtp": "^1.0.0-beta.5",
"@logto/connector-twilio-sms": "^1.0.0-beta.5",
"@logto/connector-types": "^1.0.0-beta.5",
"@logto/connector-wechat-native": "^1.0.0-beta.5",
"@logto/connector-wechat-web": "^1.0.0-beta.5",
"@logto/phrases": "^1.0.0-beta.5",

View file

@ -1,5 +1,8 @@
import { ConnectorPlatform } from '@logto/connector-types';
import { ConnectorPlatform } from '@logto/connector-core';
import { Connector, ConnectorMetadata, ConnectorType } from '@logto/schemas';
import { any } from 'zod';
import { LogtoConnector } from '@/connectors/types';
export const mockMetadata: ConnectorMetadata = {
id: 'id',
@ -31,6 +34,14 @@ export const mockConnector: Connector = {
createdAt: 1_234_567_890_123,
};
export const mockLogtoConnector: Omit<LogtoConnector, 'metadata' | 'dbEntry'> = {
getAuthorizationUri: jest.fn(),
getUserInfo: jest.fn(),
sendMessage: jest.fn(),
validateConfig: jest.fn(),
configGuard: any(),
};
const mockMetadata0: ConnectorMetadata = {
...mockMetadata,
id: 'id0',
@ -146,42 +157,46 @@ export const mockConnectorList: Connector[] = [
mockConnector6,
];
export const mockConnectorInstanceList: Array<{
connector: Connector;
metadata: ConnectorMetadata;
}> = [
export const mockLogtoConnectorList: LogtoConnector[] = [
{
connector: mockConnector0,
dbEntry: mockConnector0,
metadata: { ...mockMetadata0, type: ConnectorType.Social },
...mockLogtoConnector,
},
{
connector: mockConnector1,
dbEntry: mockConnector1,
metadata: mockMetadata1,
...mockLogtoConnector,
},
{
connector: mockConnector2,
dbEntry: mockConnector2,
metadata: mockMetadata2,
...mockLogtoConnector,
},
{
connector: mockConnector3,
dbEntry: mockConnector3,
metadata: mockMetadata3,
...mockLogtoConnector,
},
{
connector: mockConnector4,
dbEntry: mockConnector4,
metadata: { ...mockMetadata4, type: ConnectorType.Email, platform: null },
...mockLogtoConnector,
},
{
connector: mockConnector5,
dbEntry: mockConnector5,
metadata: { ...mockMetadata5, type: ConnectorType.SMS, platform: null },
...mockLogtoConnector,
},
{
connector: mockConnector6,
dbEntry: mockConnector6,
metadata: { ...mockMetadata6, type: ConnectorType.Email, platform: null },
...mockLogtoConnector,
},
];
export const mockAliyunDmConnectorInstance = {
connector: {
export const mockAliyunDmConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'aliyun-dm',
},
@ -192,10 +207,11 @@ export const mockAliyunDmConnectorInstance = {
type: ConnectorType.Email,
platform: null,
},
...mockLogtoConnector,
};
export const mockAliyunSmsConnectorInstance = {
connector: {
export const mockAliyunSmsConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'aliyun-sms',
},
@ -206,10 +222,11 @@ export const mockAliyunSmsConnectorInstance = {
type: ConnectorType.SMS,
platform: null,
},
...mockLogtoConnector,
};
export const mockFacebookConnectorInstance = {
connector: {
export const mockFacebookConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'facebook',
},
@ -220,10 +237,11 @@ export const mockFacebookConnectorInstance = {
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
...mockLogtoConnector,
};
export const mockGithubConnectorInstance = {
connector: {
export const mockGithubConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'github',
},
@ -234,10 +252,11 @@ export const mockGithubConnectorInstance = {
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
...mockLogtoConnector,
};
export const mockWechatConnectorInstance = {
connector: {
export const mockWechatConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'wechat-web',
},
@ -248,10 +267,11 @@ export const mockWechatConnectorInstance = {
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
...mockLogtoConnector,
};
export const mockWechatNativeConnectorInstance = {
connector: {
export const mockWechatNativeConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'wechat-native',
},
@ -262,10 +282,11 @@ export const mockWechatNativeConnectorInstance = {
type: ConnectorType.Social,
platform: ConnectorPlatform.Native,
},
...mockLogtoConnector,
};
export const mockGoogleConnectorInstance = {
connector: {
export const mockGoogleConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'google',
enabled: false,
@ -277,14 +298,15 @@ export const mockGoogleConnectorInstance = {
type: ConnectorType.Social,
platform: ConnectorPlatform.Web,
},
...mockLogtoConnector,
};
export const mockConnectorInstances = [
mockAliyunDmConnectorInstance,
mockAliyunSmsConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockGoogleConnectorInstance,
mockWechatConnectorInstance,
mockWechatNativeConnectorInstance,
export const mockLogtoConnectors = [
mockAliyunDmConnector,
mockAliyunSmsConnector,
mockFacebookConnector,
mockGithubConnector,
mockGoogleConnector,
mockWechatConnector,
mockWechatNativeConnector,
];

View file

@ -1,3 +1,7 @@
import { ConnectorError, ConnectorErrorCodes, GeneralConnector } from '@logto/connector-core';
import { LogtoConnector } from './types';
export const defaultConnectorPackages = [
'@logto/connector-alipay-web',
'@logto/connector-alipay-native',
@ -14,3 +18,15 @@ export const defaultConnectorPackages = [
'@logto/connector-wechat-web',
'@logto/connector-wechat-native',
];
const notImplemented = () => {
throw new ConnectorError(ConnectorErrorCodes.NotImplemented);
};
export const defaultConnectorMethods: Omit<LogtoConnector, 'metadata' | 'configGuard' | 'dbEntry'> =
{
getAuthorizationUri: notImplemented,
getUserInfo: notImplemented,
sendMessage: notImplemented,
validateConfig: notImplemented,
};

View file

@ -1,12 +1,7 @@
import { ConnectorPlatform } from '@logto/connector-types';
import { ConnectorPlatform } from '@logto/connector-core';
import { Connector } from '@logto/schemas';
import {
getConnectorInstanceById,
getConnectorInstances,
getSocialConnectorInstanceById,
initConnectors,
} from '@/connectors/index';
import { getLogtoConnectorById, getLogtoConnectors, initConnectors } from '@/connectors';
import RequestError from '@/errors/RequestError';
const alipayConnector = {
@ -120,26 +115,26 @@ jest.mock('@/queries/connector', () => ({
insertConnector: async (connector: Connector) => insertConnector(connector),
}));
describe('getConnectorInstances', () => {
describe('getLogtoConnectors', () => {
test('should return the connectors existing in DB', async () => {
const connectorInstances = await getConnectorInstances();
expect(connectorInstances).toHaveLength(connectors.length);
const logtoConnectors = await getLogtoConnectors();
expect(logtoConnectors).toHaveLength(connectors.length);
for (const [index, connector] of connectors.entries()) {
expect(connectorInstances[index]).toHaveProperty('connector', connector);
expect(logtoConnectors[index]).toHaveProperty('dbEntry', connector);
}
});
test('should throw if any required connector does not exist in DB', async () => {
const id = 'aliyun-dm';
findAllConnectors.mockImplementationOnce(async () => []);
await expect(getConnectorInstances()).rejects.toMatchError(
await expect(getLogtoConnectors()).rejects.toMatchError(
new RequestError({ code: 'entity.not_found', id, status: 404 })
);
});
test('should access DB only once and should not throw', async () => {
await expect(getConnectorInstances()).resolves.not.toThrow();
await expect(getLogtoConnectors()).resolves.not.toThrow();
expect(findAllConnectors).toHaveBeenCalled();
});
@ -148,24 +143,24 @@ describe('getConnectorInstances', () => {
});
});
describe('getConnectorInstanceById', () => {
describe('getLogtoConnectorBy', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should return the connector existing in DB', async () => {
const connectorInstance = await getConnectorInstanceById('aliyun-direct-mail');
expect(connectorInstance).toHaveProperty('connector', aliyunDmConnector);
const connector = await getLogtoConnectorById('github-universal');
expect(connector).toHaveProperty('dbEntry', githubConnector);
});
test('should throw on invalid id (on DB query)', async () => {
const id = 'invalid_id';
await expect(getConnectorInstanceById(id)).rejects.toThrow();
await expect(getLogtoConnectorById(id)).rejects.toThrow();
});
test('should throw on invalid id (on finding metadata)', async () => {
const id = 'invalid_id';
await expect(getConnectorInstanceById(id)).rejects.toMatchError(
await expect(getLogtoConnectorById(id)).rejects.toMatchError(
new RequestError({
code: 'entity.not_found',
target: 'invalid_target',
@ -176,24 +171,6 @@ describe('getConnectorInstanceById', () => {
});
});
describe('getSocialConnectorInstanceById', () => {
test('should return the connector existing in DB', async () => {
const socialConnectorInstance = await getSocialConnectorInstanceById('google-universal');
expect(socialConnectorInstance).toHaveProperty('connector', googleConnector);
});
test('should throw on non-social connector', async () => {
const id = 'aliyun-direct-mail';
await expect(getSocialConnectorInstanceById(id)).rejects.toMatchError(
new RequestError({
code: 'entity.not_found',
id,
status: 404,
})
);
});
});
describe('initConnectors', () => {
test('should insert the necessary connector if it does not exist in DB', async () => {
findAllConnectors.mockImplementationOnce(async () => []);

View file

@ -1,19 +1,19 @@
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { ConnectorInstance, SocialConnectorInstance } from '@logto/connector-types';
import { CreateConnector, GeneralConnector, validateConfig } from '@logto/connector-core';
import resolvePackagePath from 'resolve-package-path';
import envSet from '@/env-set';
import RequestError from '@/errors/RequestError';
import { findAllConnectors, insertConnector } from '@/queries/connector';
import { defaultConnectorPackages } from './consts';
import { ConnectorType } from './types';
import { getConnectorConfig } from './utilities';
import { defaultConnectorMethods, defaultConnectorPackages } from './consts';
import { LogtoConnector } from './types';
import { getConnectorConfig, validateConnectorModule } from './utilities';
// eslint-disable-next-line @silverhand/fp/no-let
let cachedConnectors: ConnectorInstance[] | undefined;
let cachedConnectors: Array<Omit<LogtoConnector, 'dbEntry'>> | undefined;
const loadConnectors = async () => {
if (cachedConnectors) {
@ -29,73 +29,83 @@ const loadConnectors = async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
cachedConnectors = await Promise.all(
connectorPackages.map(async (packageName) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { default: Builder } = await import(packageName);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const instance: ConnectorInstance = new Builder(getConnectorConfig);
// eslint-disable-next-line no-restricted-syntax
const { default: createConnector } = (await import(packageName)) as {
default: CreateConnector<GeneralConnector>;
};
const rawConnector = await createConnector({ getConfig: getConnectorConfig });
validateConnectorModule(rawConnector);
const connector: Omit<LogtoConnector, 'dbEntry'> = {
...defaultConnectorMethods,
...rawConnector,
validateConfig: (config: unknown) => {
validateConfig(config, rawConnector.configGuard);
},
};
// eslint-disable-next-line unicorn/prefer-module
const packagePath = resolvePackagePath(packageName, __dirname);
// For relative path logo url, try to read local asset.
if (
packagePath &&
!instance.metadata.logo.startsWith('http') &&
existsSync(path.join(packagePath, '..', instance.metadata.logo))
!connector.metadata.logo.startsWith('http') &&
existsSync(path.join(packagePath, '..', connector.metadata.logo))
) {
const data = readFileSync(path.join(packagePath, '..', instance.metadata.logo));
const data = readFileSync(path.join(packagePath, '..', connector.metadata.logo));
// eslint-disable-next-line @silverhand/fp/no-mutation
instance.metadata.logo = `data:image/svg+xml;base64,${data.toString('base64')}`;
connector.metadata.logo = `data:image/svg+xml;base64,${data.toString('base64')}`;
}
if (
packagePath &&
instance.metadata.logoDark &&
!instance.metadata.logoDark.startsWith('http') &&
existsSync(path.join(packagePath, '..', instance.metadata.logoDark))
connector.metadata.logoDark &&
!connector.metadata.logoDark.startsWith('http') &&
existsSync(path.join(packagePath, '..', connector.metadata.logoDark))
) {
const data = readFileSync(path.join(packagePath, '..', instance.metadata.logoDark));
const data = readFileSync(path.join(packagePath, '..', connector.metadata.logoDark));
// eslint-disable-next-line @silverhand/fp/no-mutation
instance.metadata.logoDark = `data:image/svg+xml;base64,${data.toString('base64')}`;
connector.metadata.logoDark = `data:image/svg+xml;base64,${data.toString('base64')}`;
}
if (
packagePath &&
instance.metadata.readme &&
existsSync(path.join(packagePath, '..', instance.metadata.readme))
connector.metadata.readme &&
existsSync(path.join(packagePath, '..', connector.metadata.readme))
) {
// eslint-disable-next-line @silverhand/fp/no-mutation
instance.metadata.readme = readFileSync(
path.join(packagePath, '..', instance.metadata.readme),
connector.metadata.readme = readFileSync(
path.join(packagePath, '..', connector.metadata.readme),
'utf8'
);
}
if (
packagePath &&
instance.metadata.configTemplate &&
existsSync(path.join(packagePath, '..', instance.metadata.configTemplate))
connector.metadata.configTemplate &&
existsSync(path.join(packagePath, '..', connector.metadata.configTemplate))
) {
// eslint-disable-next-line @silverhand/fp/no-mutation
instance.metadata.configTemplate = readFileSync(
path.join(packagePath, '..', instance.metadata.configTemplate),
connector.metadata.configTemplate = readFileSync(
path.join(packagePath, '..', connector.metadata.configTemplate),
'utf8'
);
}
return instance;
return connector;
})
);
return cachedConnectors;
};
export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
export const getLogtoConnectors = async (): Promise<LogtoConnector[]> => {
const connectors = await findAllConnectors();
const connectorMap = new Map(connectors.map((connector) => [connector.id, connector]));
const allConnectors = await loadConnectors();
const logtoConnectors = await loadConnectors();
return allConnectors.map((element) => {
return logtoConnectors.map((element) => {
const { id } = element.metadata;
const connector = connectorMap.get(id);
@ -103,18 +113,18 @@ export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
throw new RequestError({ code: 'entity.not_found', id, status: 404 });
}
// eslint-disable-next-line @silverhand/fp/no-mutation
element.connector = connector;
return element;
return {
...element,
dbEntry: connector,
};
});
};
export const getConnectorInstanceById = async (id: string): Promise<ConnectorInstance> => {
const connectorInstances = await getConnectorInstances();
const pickedConnectorInstance = connectorInstances.find(({ connector }) => connector.id === id);
export const getLogtoConnectorById = async (id: string): Promise<LogtoConnector> => {
const connectors = await getLogtoConnectors();
const pickedConnector = connectors.find(({ dbEntry }) => dbEntry.id === id);
if (!pickedConnectorInstance) {
if (!pickedConnector) {
throw new RequestError({
code: 'entity.not_found',
id,
@ -122,29 +132,7 @@ export const getConnectorInstanceById = async (id: string): Promise<ConnectorIns
});
}
return pickedConnectorInstance;
};
const isSocialConnectorInstance = (
connector: ConnectorInstance
): connector is SocialConnectorInstance => {
return connector.metadata.type === ConnectorType.Social;
};
export const getSocialConnectorInstanceById = async (
id: string
): Promise<SocialConnectorInstance> => {
const connector = await getConnectorInstanceById(id);
if (!isSocialConnectorInstance(connector)) {
throw new RequestError({
code: 'entity.not_found',
id,
status: 404,
});
}
return connector;
return pickedConnector;
};
export const initConnectors = async () => {

View file

@ -1,8 +1,8 @@
import { PasscodeType } from '@logto/schemas';
import { GeneralConnector } from '@logto/connector-core';
import { Connector, PasscodeType } from '@logto/schemas';
import { z } from 'zod';
export { ConnectorType } from '@logto/schemas';
export type { ConnectorMetadata } from '@logto/schemas';
export type TemplateType = PasscodeType | 'Test';
@ -15,3 +15,8 @@ export const socialUserInfoGuard = z.object({
});
export type SocialUserInfo = z.infer<typeof socialUserInfoGuard>;
export type LogtoConnector = Required<GeneralConnector> & {
dbEntry: Connector;
validateConfig: (config: unknown) => void;
};

View file

@ -1,3 +1,5 @@
import { ConnectorError, ConnectorErrorCodes, GeneralConnector } from '@logto/connector-core';
import RequestError from '@/errors/RequestError';
import { findAllConnectors } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
@ -10,3 +12,15 @@ export const getConnectorConfig = async (id: string): Promise<unknown> => {
return connector.config;
};
export function validateConnectorModule(
connector: Partial<GeneralConnector>
): asserts connector is GeneralConnector {
if (!connector.metadata) {
throw new ConnectorError(ConnectorErrorCodes.InvalidMetadata);
}
if (!connector.configGuard) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfigGuard);
}
}

View file

@ -1,8 +1,10 @@
import { ConnectorType } from '@logto/connector-types';
import { ConnectorType } from '@logto/connector-core';
import { Passcode, PasscodeType } from '@logto/schemas';
import { any } from 'zod';
import { mockConnector, mockMetadata } from '@/__mocks__';
import { getConnectorInstances } from '@/connectors';
import { getLogtoConnectors } from '@/connectors';
import { defaultConnectorMethods } from '@/connectors/consts';
import RequestError from '@/errors/RequestError';
import {
consumePasscode,
@ -37,8 +39,8 @@ const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction<
typeof deletePasscodesByIds
>;
const mockedInsertPasscode = insertPasscode as jest.MockedFunction<typeof insertPasscode>;
const mockedGetConnectorInstances = getConnectorInstances as jest.MockedFunction<
typeof getConnectorInstances
const mockedGetLogtoConnectors = getLogtoConnectors as jest.MockedFunction<
typeof getLogtoConnectors
>;
const mockedConsumePasscode = consumePasscode as jest.MockedFunction<typeof consumePasscode>;
const mockedIncreasePasscodeTryCount = increasePasscodeTryCount as jest.MockedFunction<
@ -124,12 +126,10 @@ describe('sendPasscode', () => {
});
it('should throw error when email or sms connector can not be found', async () => {
const sendMessage = jest.fn();
const validateConfig = jest.fn();
const getConfig = jest.fn();
mockedGetConnectorInstances.mockResolvedValueOnce([
mockedGetLogtoConnectors.mockResolvedValueOnce([
{
connector: {
...defaultConnectorMethods,
dbEntry: {
...mockConnector,
id: 'id1',
},
@ -138,9 +138,7 @@ describe('sendPasscode', () => {
type: ConnectorType.Email,
platform: null,
},
sendMessage,
validateConfig,
getConfig,
configGuard: any(),
},
]);
const passcode: Passcode = {
@ -164,11 +162,11 @@ describe('sendPasscode', () => {
it('should call sendPasscode with params matching', async () => {
const sendMessage = jest.fn();
const validateConfig = jest.fn();
const getConfig = jest.fn();
mockedGetConnectorInstances.mockResolvedValueOnce([
mockedGetLogtoConnectors.mockResolvedValueOnce([
{
connector: {
...defaultConnectorMethods,
configGuard: any(),
dbEntry: {
...mockConnector,
id: 'id0',
},
@ -178,11 +176,11 @@ describe('sendPasscode', () => {
platform: null,
},
sendMessage,
validateConfig,
getConfig,
},
{
connector: {
...defaultConnectorMethods,
configGuard: any(),
dbEntry: {
...mockConnector,
id: 'id1',
},
@ -192,8 +190,6 @@ describe('sendPasscode', () => {
platform: null,
},
sendMessage,
validateConfig,
getConfig,
},
]);
const passcode: Passcode = {
@ -208,8 +204,12 @@ describe('sendPasscode', () => {
createdAt: Date.now(),
};
await sendPasscode(passcode);
expect(sendMessage).toHaveBeenCalledWith(passcode.phone, passcode.type, {
code: passcode.code,
expect(sendMessage).toHaveBeenCalledWith({
to: passcode.phone,
type: passcode.type,
payload: {
code: passcode.code,
},
});
});
});

View file

@ -1,8 +1,8 @@
import { EmailConnectorInstance, SmsConnectorInstance } from '@logto/connector-types';
import { messageTypesGuard, ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import { Passcode, PasscodeType } from '@logto/schemas';
import { customAlphabet, nanoid } from 'nanoid';
import { getConnectorInstances } from '@/connectors';
import { getLogtoConnectors } from '@/connectors';
import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
@ -46,34 +46,42 @@ export const sendPasscode = async (passcode: Passcode) => {
throw new RequestError('passcode.phone_email_empty');
}
const connectorInstances = await getConnectorInstances();
const connectors = await getLogtoConnectors();
const emailConnectorInstance = connectorInstances.find(
(connector): connector is EmailConnectorInstance =>
connector.connector.enabled && connector.metadata.type === ConnectorType.Email
const emailConnector = connectors.find(
(connector) => connector.dbEntry.enabled && connector.metadata.type === ConnectorType.Email
);
const smsConnectorInstance = connectorInstances.find(
(connector): connector is SmsConnectorInstance =>
connector.connector.enabled && connector.metadata.type === ConnectorType.SMS
const smsConnector = connectors.find(
(connector) => connector.dbEntry.enabled && connector.metadata.type === ConnectorType.SMS
);
const connectorInstance = passcode.email ? emailConnectorInstance : smsConnectorInstance;
const connector = passcode.email ? emailConnector : smsConnector;
assertThat(
connectorInstance,
connector,
new RequestError({
code: 'connector.not_found',
type: passcode.email ? ConnectorType.Email : ConnectorType.SMS,
})
);
const { connector, metadata, sendMessage } = connectorInstance;
const { dbEntry, metadata, sendMessage } = connector;
const response = await sendMessage(emailOrPhone, passcode.type, {
code: passcode.code,
const messageTypeResult = messageTypesGuard.safeParse(passcode.type);
if (!messageTypeResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
}
const response = await sendMessage({
to: emailOrPhone,
type: messageTypeResult.data,
payload: {
code: passcode.code,
},
});
return { connector, metadata, response };
return { dbEntry, metadata, response };
};
export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes.

View file

@ -1,10 +1,9 @@
import { ConnectorInstance } from '@logto/connector-types';
import { BrandingStyle, SignInMethodState, ConnectorType } from '@logto/schemas';
import {
mockAliyunDmConnectorInstance,
mockFacebookConnectorInstance,
mockGithubConnectorInstance,
mockAliyunDmConnector,
mockFacebookConnector,
mockGithubConnector,
mockBranding,
mockSignInMethods,
} from '@/__mocks__';
@ -16,7 +15,7 @@ import {
validateTermsOfUse,
} from '@/lib/sign-in-experience';
const enabledConnectorInstances = [mockFacebookConnectorInstance, mockGithubConnectorInstance];
const enabledConnectors = [mockFacebookConnector, mockGithubConnector];
describe('validate branding', () => {
test('should throw when the UI style contains the slogan and slogan is empty', () => {
@ -113,7 +112,7 @@ describe('validate sign-in methods', () => {
validateSignInMethods(
{ ...mockSignInMethods, email: SignInMethodState.Secondary },
[],
enabledConnectorInstances as ConnectorInstance[]
enabledConnectors
);
}).toMatchError(
new RequestError({
@ -128,7 +127,7 @@ describe('validate sign-in methods', () => {
validateSignInMethods(
{ ...mockSignInMethods, sms: SignInMethodState.Secondary },
[],
enabledConnectorInstances as ConnectorInstance[]
enabledConnectors
);
}).toMatchError(
new RequestError({
@ -140,9 +139,11 @@ describe('validate sign-in methods', () => {
test('should throw when there is no enabled social connector and social sign-in method is enabled', () => {
expect(() => {
validateSignInMethods({ ...mockSignInMethods, social: SignInMethodState.Secondary }, [], [
mockAliyunDmConnectorInstance,
] as ConnectorInstance[]);
validateSignInMethods(
{ ...mockSignInMethods, social: SignInMethodState.Secondary },
[],
[mockAliyunDmConnector]
);
}).toMatchError(
new RequestError({
code: 'sign_in_experiences.enabled_connector_not_found',
@ -157,7 +158,7 @@ describe('validate sign-in methods', () => {
validateSignInMethods(
{ ...mockSignInMethods, social: SignInMethodState.Secondary },
[],
enabledConnectorInstances as ConnectorInstance[]
enabledConnectors
);
}).toMatchError(new RequestError('sign_in_experiences.empty_social_connectors'));
});

View file

@ -1,4 +1,3 @@
import { ConnectorInstance } from '@logto/connector-types';
import {
Branding,
BrandingStyle,
@ -8,7 +7,7 @@ import {
} from '@logto/schemas';
import { Optional } from '@silverhand/essentials';
import { ConnectorType } from '@/connectors/types';
import { ConnectorType, LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
@ -32,7 +31,7 @@ export const isEnabled = (state: SignInMethodState) => state !== SignInMethodSta
export const validateSignInMethods = (
signInMethods: SignInMethods,
socialSignInConnectorTargets: Optional<string[]>,
enabledConnectorInstances: ConnectorInstance[]
enabledConnectors: LogtoConnector[]
) => {
const signInMethodStates = Object.values(signInMethods);
assertThat(
@ -42,7 +41,7 @@ export const validateSignInMethods = (
if (isEnabled(signInMethods.email)) {
assertThat(
enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.Email),
enabledConnectors.some((item) => item.metadata.type === ConnectorType.Email),
new RequestError({
code: 'sign_in_experiences.enabled_connector_not_found',
type: ConnectorType.Email,
@ -52,7 +51,7 @@ export const validateSignInMethods = (
if (isEnabled(signInMethods.sms)) {
assertThat(
enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.SMS),
enabledConnectors.some((item) => item.metadata.type === ConnectorType.SMS),
new RequestError({
code: 'sign_in_experiences.enabled_connector_not_found',
type: ConnectorType.SMS,
@ -62,7 +61,7 @@ export const validateSignInMethods = (
if (isEnabled(signInMethods.social)) {
assertThat(
enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.Social),
enabledConnectors.some((item) => item.metadata.type === ConnectorType.Social),
new RequestError({
code: 'sign_in_experiences.enabled_connector_not_found',
type: ConnectorType.Social,

View file

@ -3,7 +3,7 @@ import { Nullable } from '@silverhand/essentials';
import { InteractionResults } from 'oidc-provider';
import { z } from 'zod';
import { getSocialConnectorInstanceById } from '@/connectors';
import { getLogtoConnectorById } from '@/connectors';
import { SocialUserInfo, socialUserInfoGuard } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
@ -21,7 +21,7 @@ export type SocialUserInfoSession = {
const getConnector = async (connectorId: string) => {
try {
return await getSocialConnectorInstanceById(connectorId);
return await getLogtoConnectorById(connectorId);
} catch (error: unknown) {
// Throw a new error with status 422 when connector not found.
if (error instanceof RequestError && error.code === 'entity.not_found') {

View file

@ -1,4 +1,4 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import RequestError from '@/errors/RequestError';
import { createContextWithRouteParameters } from '@/utils/test-utils';

View file

@ -1,4 +1,4 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-types';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import { conditional } from '@silverhand/essentials';
import { Middleware } from 'koa';
import { z } from 'zod';

View file

@ -1,34 +1,25 @@
import {
ValidateConfig,
EmailConnectorInstance,
SmsConnectorInstance,
} from '@logto/connector-types';
import { MessageTypes } from '@logto/connector-core';
import { Connector, ConnectorType } from '@logto/schemas';
import { any } from 'zod';
import { mockConnectorInstanceList, mockMetadata, mockConnector } from '@/__mocks__';
import { ConnectorMetadata } from '@/connectors/types';
import { mockMetadata, mockConnector, mockLogtoConnectorList } from '@/__mocks__';
import { defaultConnectorMethods } from '@/connectors/consts';
import { LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
import { createRequester } from '@/utils/test-utils';
import connectorRoutes from './connector';
type ConnectorInstance = {
connector: Connector;
metadata: ConnectorMetadata;
validateConfig?: ValidateConfig;
sendMessage?: unknown;
};
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorInstance[]>
const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
jest.mock('@/connectors', () => ({
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
getConnectorInstanceById: async (connectorId: string) => {
const connectorInstances = await getConnectorInstancesPlaceHolder();
const connector = connectorInstances.find(({ connector }) => connector.id === connectorId);
getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(),
getLogtoConnectorById: async (connectorId: string) => {
const connectors = await getLogtoConnectorsPlaceHolder();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
connector,
new RequestError({
@ -51,15 +42,15 @@ describe('connector route', () => {
});
it('throws if more than one email connector is enabled', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector is enabled', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(
mockConnectorInstanceList.filter(
(connectorInstance) => connectorInstance.metadata.type !== ConnectorType.Email
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
mockLogtoConnectorList.filter(
(connector) => connector.metadata.type !== ConnectorType.Email
)
);
const response = await connectorRequest.get('/connectors').send({});
@ -67,9 +58,9 @@ describe('connector route', () => {
});
it('shows all connectors', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(
mockConnectorInstanceList.filter(
(connectorInstance) => connectorInstance.metadata.type === ConnectorType.Social
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
mockLogtoConnectorList.filter(
(connector) => connector.metadata.type === ConnectorType.Social
)
);
const response = await connectorRequest.get('/connectors').send({});
@ -83,19 +74,19 @@ describe('connector route', () => {
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(2));
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList.slice(2));
const response = await connectorRequest.get('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('shows found connector information', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
@ -111,44 +102,56 @@ describe('connector route', () => {
...mockMetadata,
type: ConnectorType.SMS,
};
const mockedSmsConnectorInstance: SmsConnectorInstance = {
connector: mockConnector,
const sendMessage = jest.fn();
const mockedSmsConnector: LogtoConnector = {
dbEntry: mockConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: jest.fn(),
sendTestMessage: jest.fn(),
configGuard: any(),
...defaultConnectorMethods,
sendMessage,
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedSmsConnectorInstance]);
const sendMessageSpy = jest.spyOn(mockedSmsConnectorInstance, 'sendTestMessage');
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedSmsConnector]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901', config: { test: 123 } });
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith({ test: 123 }, '12345678901', 'Test', {
code: '123456',
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: '12345678901',
type: MessageTypes.Test,
payload: {
code: '123456',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
it('should get email connector and send test message', async () => {
const mockedEmailConnector: EmailConnectorInstance = {
connector: mockConnector,
const sendMessage = jest.fn();
const mockedEmailConnector: LogtoConnector = {
dbEntry: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: jest.fn(),
sendTestMessage: jest.fn(),
configGuard: any(),
...defaultConnectorMethods,
sendMessage,
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedEmailConnector]);
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendTestMessage');
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedEmailConnector]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com', config: { test: 123 } });
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith({ test: 123 }, 'test@email.com', 'Test', {
code: 'email-test',
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: 'test@email.com',
type: MessageTypes.Test,
payload: {
code: 'email-test',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
@ -158,7 +161,7 @@ describe('connector route', () => {
});
it('should throw when sms connector is not found', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901' });
@ -166,7 +169,7 @@ describe('connector route', () => {
});
it('should throw when email connector is not found', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com' });

View file

@ -1,13 +1,10 @@
import {
ConnectorInstance,
EmailConnectorInstance,
SmsConnectorInstance,
} from '@logto/connector-types';
import { MessageTypes } from '@logto/connector-core';
import { arbitraryObjectGuard, ConnectorDto, Connectors, ConnectorType } from '@logto/schemas';
import { emailRegEx, phoneRegEx } from '@logto/shared';
import { object, string } from 'zod';
import { getConnectorInstances, getConnectorInstanceById } from '@/connectors';
import { getLogtoConnectorById, getLogtoConnectors } from '@/connectors';
import { LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import koaGuard from '@/middleware/koa-guard';
import { updateConnector } from '@/queries/connector';
@ -15,8 +12,8 @@ import assertThat from '@/utils/assert-that';
import { AuthedRouter } from './types';
const transpileConnectorInstance = ({ connector, metadata }: ConnectorInstance): ConnectorDto => ({
...connector,
const transpileLogtoConnector = ({ dbEntry, metadata }: LogtoConnector): ConnectorDto => ({
...dbEntry,
...metadata,
});
@ -30,30 +27,27 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
}),
async (ctx, next) => {
const { target: filterTarget } = ctx.query;
const connectorInstances = await getConnectorInstances();
const connectors = await getLogtoConnectors();
assertThat(
connectorInstances.filter(
connectors.filter(
(connector) =>
connector.connector.enabled && connector.metadata.type === ConnectorType.Email
connector.dbEntry.enabled && connector.metadata.type === ConnectorType.Email
).length <= 1,
'connector.more_than_one_email'
);
assertThat(
connectorInstances.filter(
(connector) =>
connector.connector.enabled && connector.metadata.type === ConnectorType.SMS
connectors.filter(
(connector) => connector.dbEntry.enabled && connector.metadata.type === ConnectorType.SMS
).length <= 1,
'connector.more_than_one_sms'
);
const filteredInstances = filterTarget
? connectorInstances.filter(({ metadata: { target } }) => target === filterTarget)
: connectorInstances;
const filteredConnectors = filterTarget
? connectors.filter(({ metadata: { target } }) => target === filterTarget)
: connectors;
ctx.body = filteredInstances.map((connectorInstance) =>
transpileConnectorInstance(connectorInstance)
);
ctx.body = filteredConnectors.map((connector) => transpileLogtoConnector(connector));
return next();
}
@ -66,8 +60,8 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
const {
params: { id },
} = ctx.guard;
const connectorInstance = await getConnectorInstanceById(id);
ctx.body = transpileConnectorInstance(connectorInstance);
const connector = await getLogtoConnectorById(id);
ctx.body = transpileLogtoConnector(connector);
return next();
}
@ -85,21 +79,14 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
body: { enabled },
} = ctx.guard;
const connectorInstance = await getConnectorInstanceById(id);
const {
connector: { config },
validateConfig,
dbEntry: { config },
metadata,
} = connectorInstance;
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
const validator: typeof validateConfig = validateConfig;
validateConfig,
} = await getLogtoConnectorById(id);
if (enabled) {
validator(config);
validateConfig(config);
}
// Only allow one enabled connector for SMS and Email.
@ -108,14 +95,13 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
enabled &&
(metadata.type === ConnectorType.SMS || metadata.type === ConnectorType.Email)
) {
const connectors = await getConnectorInstances();
const connectors = await getLogtoConnectors();
await Promise.all(
connectors
.filter(
(connector) =>
connector.metadata.type === metadata.type && connector.connector.enabled
({ dbEntry: { enabled }, metadata: { type } }) => type === metadata.type && enabled
)
.map(async ({ connector: { id } }) =>
.map(async ({ dbEntry: { id } }) =>
updateConnector({ set: { enabled: false }, where: { id }, jsonbMode: 'merge' })
)
);
@ -144,16 +130,10 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
body,
} = ctx.guard;
const { metadata, validateConfig } = await getConnectorInstanceById(id);
/**
* Assertion functions always need explicit annotations.
* See https://github.com/microsoft/TypeScript/issues/36931#issuecomment-589753014
*/
const validator: typeof validateConfig = validateConfig;
const { metadata, validateConfig } = await getLogtoConnectorById(id);
if (body.config) {
validator(body.config);
validateConfig(body.config);
}
const connector = await updateConnector({ set: body, where: { id }, jsonbMode: 'replace' });
@ -180,18 +160,16 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
} = ctx.guard;
const { phone, email, config } = body;
const connectorInstances = await getConnectorInstances();
const logtoConnectors = await getLogtoConnectors();
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connector: SmsConnectorInstance | EmailConnectorInstance | undefined = phone
? connectorInstances.find(
(connector): connector is SmsConnectorInstance =>
connector.metadata.id === id && connector.metadata.type === ConnectorType.SMS
const connector = phone
? logtoConnectors.find(
({ metadata: { id: id_, type } }) => id_ === id && type === ConnectorType.SMS
)
: connectorInstances.find(
(connector): connector is EmailConnectorInstance =>
connector.metadata.id === id && connector.metadata.type === ConnectorType.Email
: logtoConnectors.find(
({ metadata: { id: id_, type } }) => id_ === id && type === ConnectorType.Email
);
assertThat(
@ -202,19 +180,18 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
})
);
const { sendTestMessage } = connector;
assertThat(
sendTestMessage,
new RequestError({
code: 'connector.not_implemented',
method: 'sendTestMessage',
status: 501,
})
);
const { sendMessage } = connector;
await sendTestMessage(config, subject, 'Test', {
code: phone ? '123456' : 'email-test',
});
await sendMessage(
{
to: subject,
type: MessageTypes.Test,
payload: {
code: phone ? '123456' : 'email-test',
},
},
config
);
ctx.status = 204;

View file

@ -1,8 +1,13 @@
import { ConnectorError, ConnectorErrorCodes, ValidateConfig } from '@logto/connector-types';
import { Connector, ConnectorType } from '@logto/schemas';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-core';
import { ConnectorType } from '@logto/schemas';
import { mockConnectorInstanceList, mockMetadata, mockConnector } from '@/__mocks__';
import { ConnectorMetadata } from '@/connectors/types';
import {
mockMetadata,
mockConnector,
mockLogtoConnectorList,
mockLogtoConnector,
} from '@/__mocks__';
import { LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { updateConnector } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
@ -10,19 +15,13 @@ import { createRequester } from '@/utils/test-utils';
import connectorRoutes from './connector';
type ConnectorInstance = {
connector: Connector;
metadata: ConnectorMetadata;
validateConfig?: ValidateConfig;
sendMessage?: unknown;
};
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorInstance[]>
const getLogtoConnectorsPlaceholder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
const getConnectorInstanceByIdPlaceHolder = jest.fn(async (connectorId: string) => {
const connectorInstances = await getConnectorInstancesPlaceHolder();
const connector = connectorInstances.find(({ connector }) => connector.id === connectorId);
const getLogtoConnectorByIdPlaceholder = jest.fn(async (connectorId: string) => {
const connectors = await getLogtoConnectorsPlaceholder();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
connector,
new RequestError({
@ -34,20 +33,18 @@ const getConnectorInstanceByIdPlaceHolder = jest.fn(async (connectorId: string)
return {
...connector,
validateConfig: validateConfigPlaceHolder,
sendMessage: sendMessagePlaceHolder,
};
});
const validateConfigPlaceHolder = jest.fn() as jest.MockedFunction<ValidateConfig>;
}) as jest.MockedFunction<(connectorId: string) => Promise<LogtoConnector>>;
const sendMessagePlaceHolder = jest.fn();
jest.mock('@/queries/connector', () => ({
updateConnector: jest.fn(),
}));
jest.mock('@/connectors', () => ({
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
getConnectorInstanceById: async (connectorId: string) =>
getConnectorInstanceByIdPlaceHolder(connectorId),
getLogtoConnectors: async () => getLogtoConnectorsPlaceholder(),
getLogtoConnectorById: async (connectorId: string) =>
getLogtoConnectorByIdPlaceholder(connectorId),
}));
describe('connector PATCH routes', () => {
@ -59,7 +56,7 @@ describe('connector PATCH routes', () => {
});
it('throws if connector can not be found (locally)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(1));
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(1));
const response = await connectorRequest
.patch('/connectors/findConnector/enabled')
.send({ enabled: true });
@ -67,7 +64,7 @@ describe('connector PATCH routes', () => {
});
it('throws if connector can not be found (remotely)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]);
const response = await connectorRequest
.patch('/connectors/id0/enabled')
.send({ enabled: true });
@ -75,10 +72,11 @@ describe('connector PATCH routes', () => {
});
it('enables one of the social connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: { ...mockMetadata, type: ConnectorType.Social },
...mockLogtoConnector,
},
]);
const response = await connectorRequest
@ -98,13 +96,14 @@ describe('connector PATCH routes', () => {
});
it('enables one of the social connectors (with invalid config)', async () => {
validateConfigPlaceHolder.mockImplementationOnce(() => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: mockMetadata,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
@ -114,10 +113,11 @@ describe('connector PATCH routes', () => {
});
it('disables one of the social connectors', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: mockMetadata,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
@ -137,7 +137,7 @@ describe('connector PATCH routes', () => {
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList);
const mockedMetadata = {
...mockMetadata,
id: 'id1',
@ -147,11 +147,10 @@ describe('connector PATCH routes', () => {
...mockConnector,
id: 'id1',
};
getConnectorInstanceByIdPlaceHolder.mockResolvedValueOnce({
connector: mockedConnector,
getLogtoConnectorByIdPlaceholder.mockResolvedValueOnce({
dbEntry: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
sendMessage: jest.fn(),
...mockLogtoConnector,
});
const response = await connectorRequest
.patch('/connectors/id1/enabled')
@ -187,16 +186,17 @@ describe('connector PATCH routes', () => {
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
validateConfigPlaceHolder.mockImplementationOnce(() => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: {
...mockMetadata,
type: ConnectorType.SMS,
},
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
@ -206,10 +206,11 @@ describe('connector PATCH routes', () => {
});
it('disables one of the email/sms connectors', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: mockMetadata,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
@ -235,25 +236,26 @@ describe('connector PATCH routes', () => {
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(0, 1));
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(0, 1));
const response = await connectorRequest.patch('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]);
const response = await connectorRequest.patch('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('config validation fails', async () => {
validateConfigPlaceHolder.mockImplementationOnce(() => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: mockMetadata,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
@ -263,10 +265,11 @@ describe('connector PATCH routes', () => {
});
it('successfully updates connector configs', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
connector: mockConnector,
dbEntry: mockConnector,
metadata: mockMetadata,
...mockLogtoConnector,
},
]);
const response = await connectorRequest

View file

@ -27,7 +27,7 @@ jest.mock('@/queries/user', () => ({
hasUserWithEmail: async (email: string) => email === 'a@a.com',
}));
const sendPasscode = jest.fn(async () => ({ connector: { id: 'connectorIdValue' } }));
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
sendPasscode: async () => sendPasscode(),

View file

@ -41,8 +41,8 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
@ -89,8 +89,8 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
@ -137,8 +137,8 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
@ -186,8 +186,8 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
const { connector } = await sendPasscode(passcode);
ctx.log(type, { connectorId: connector.id });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();

View file

@ -1,9 +1,9 @@
import { ConnectorType } from '@logto/connector-core';
import { User } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockUser, mockConnectorInstances } from '@/__mocks__';
import { getConnectorInstanceById } from '@/connectors';
import { ConnectorType } from '@/connectors/types';
import { mockLogtoConnectorList, mockUser } from '@/__mocks__';
import { getLogtoConnectorById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
@ -50,8 +50,8 @@ jest.mock('@/lib/user', () => ({
insertUser: async (...args: unknown[]) => insertUser(...args),
}));
const getConnectorInstanceByIdHelper = jest.fn(async (connectorId: string) => {
const connector = {
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
const database = {
enabled: connectorId === 'social_enabled',
};
const metadata = {
@ -64,23 +64,23 @@ const getConnectorInstanceByIdHelper = jest.fn(async (connectorId: string) => {
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.SMS,
};
return { connector, metadata, getAuthorizationUri: jest.fn(async () => '') };
return { dbEntry: database, metadata, getAuthorizationUri: jest.fn(async () => '') };
});
jest.mock('@/connectors', () => ({
getSocialConnectorInstanceById: async (connectorId: string) => {
const connectorInstance = await getConnectorInstanceByIdHelper(connectorId);
if (connectorInstance.metadata.type !== ConnectorType.Social) {
jest.mock('@/connectors', () => ({
getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList),
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.metadata.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connectorInstance;
},
getConnectorInstances: jest.fn(async () => mockConnectorInstances),
getConnectorInstanceById: jest.fn(),
return connector;
}),
}));
const interactionResult = jest.fn(async () => 'redirectTo');
@ -172,7 +172,7 @@ describe('session -> socialRoutes', () => {
});
it('throw error when auth code is wrong', async () => {
(getConnectorInstanceById as jest.Mock).mockResolvedValueOnce({
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
metadata: { target: connectorTarget },
});
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
@ -185,7 +185,7 @@ describe('session -> socialRoutes', () => {
});
it('throw error when code is provided but connector can not be found', async () => {
(getConnectorInstanceById as jest.Mock).mockResolvedValueOnce({
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
metadata: { target: connectorTarget },
});
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
@ -198,7 +198,7 @@ describe('session -> socialRoutes', () => {
});
it('get and add user info with auth code, as well as assign result and redirect', async () => {
(getConnectorInstanceById as jest.Mock).mockResolvedValueOnce({
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
metadata: { target: connectorTarget },
});
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
@ -226,7 +226,7 @@ describe('session -> socialRoutes', () => {
it('throw error when identity exists', async () => {
const wrongConnectorTarget = 'wrongConnectorTarget';
(getConnectorInstanceById as jest.Mock).mockResolvedValueOnce({
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
metadata: { target: wrongConnectorTarget },
});
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
@ -251,8 +251,8 @@ describe('session -> socialRoutes', () => {
describe('POST /session/sign-in/bind-social-related-user', () => {
beforeEach(() => {
const mockGetConnectorInstanceById = getConnectorInstanceById as jest.Mock;
mockGetConnectorInstanceById.mockResolvedValueOnce({
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
mockGetLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'connectorTarget' },
});
});
@ -313,8 +313,8 @@ describe('session -> socialRoutes', () => {
describe('POST /session/register/social', () => {
beforeEach(() => {
const mockGetConnectorInstanceById = getConnectorInstanceById as jest.Mock;
mockGetConnectorInstanceById.mockResolvedValueOnce({
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
mockGetLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'connectorTarget' },
});
});
@ -379,8 +379,8 @@ describe('session -> socialRoutes', () => {
describe('POST /session/bind-social', () => {
beforeEach(() => {
const mockGetConnectorInstanceById = getConnectorInstanceById as jest.Mock;
mockGetConnectorInstanceById.mockResolvedValueOnce({
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
mockGetLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'connectorTarget' },
});
});

Some files were not shown because too many files have changed in this diff Show more