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:
parent
82cd31545d
commit
8db355287c
112 changed files with 1914 additions and 2942 deletions
|
@ -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']]
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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/';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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/';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
},
|
13
packages/connector-core/src/index.ts
Normal file
13
packages/connector-core/src/index.ts
Normal 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);
|
||||
}
|
||||
}
|
107
packages/connector-core/src/types.ts
Normal file
107
packages/connector-core/src/types.ts
Normal 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>;
|
|
@ -2,10 +2,9 @@
|
|||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
// Note:Need 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));
|
||||
|
||||
// Note:Need 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;
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
15
packages/connector-smtp/src/mock.ts
Normal file
15
packages/connector-smtp/src/mock.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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))
|
|
@ -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 */
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 () => []);
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue