0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(toolkit,connector,core,cli): cache access token for logto email service (#4309)

This commit is contained in:
Darcy Ye 2023-08-11 15:43:09 +08:00 committed by GitHub
parent bc5a797892
commit 77d274a3c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 101 additions and 176 deletions

View file

@ -7,6 +7,7 @@ import type {
BaseConnector, BaseConnector,
ConnectorMetadata, ConnectorMetadata,
GetConnectorConfig, GetConnectorConfig,
GetCloudServiceClient,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit';
@ -91,11 +92,13 @@ export const parseMetadata = async (
export const buildRawConnector = async <T extends AllConnector = AllConnector>( export const buildRawConnector = async <T extends AllConnector = AllConnector>(
connectorFactory: ConnectorFactory<T>, connectorFactory: ConnectorFactory<T>,
getConnectorConfig?: GetConnectorConfig getConnectorConfig?: GetConnectorConfig,
getCloudServiceClient?: GetCloudServiceClient
): Promise<{ rawConnector: T; rawMetadata: ConnectorMetadata }> => { ): Promise<{ rawConnector: T; rawMetadata: ConnectorMetadata }> => {
const { createConnector, path: packagePath } = connectorFactory; const { createConnector, path: packagePath } = connectorFactory;
const rawConnector = await createConnector({ const rawConnector = await createConnector({
getConfig: getConnectorConfig ?? notImplemented, getConfig: getConnectorConfig ?? notImplemented,
getCloudServiceClient,
}); });
validateConnectorModule(rawConnector); validateConnectorModule(rawConnector);
const rawMetadata = await parseMetadata(rawConnector.metadata, packagePath); const rawMetadata = await parseMetadata(rawConnector.metadata, packagePath);

View file

@ -1,14 +0,0 @@
import nock from 'nock';
import { grantAccessToken } from './grant-access-token.js';
import { mockedAccessTokenResponse, mockedConfig } from './mock.js';
describe('grantAccessToken()', () => {
it('should call got.post() and return access token', async () => {
nock(mockedConfig.tokenEndpoint).post('').reply(200, JSON.stringify(mockedAccessTokenResponse));
const response = await grantAccessToken(mockedConfig);
expect(response).toMatchObject(mockedAccessTokenResponse);
});
});

View file

@ -1,42 +0,0 @@
import { got } from 'got';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { defaultTimeout, scope } from './constant.js';
import { accessTokenResponseGuard } from './types.js';
export type GrantAccessTokenParameters = {
tokenEndpoint: string;
resource: string;
appId: string;
appSecret: string;
};
export const grantAccessToken = async ({
tokenEndpoint,
resource,
appId,
appSecret,
}: GrantAccessTokenParameters) => {
const httpResponse = await got.post({
url: tokenEndpoint,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString('base64')}`,
},
timeout: { request: defaultTimeout },
form: {
grant_type: 'client_credentials',
resource,
scope,
},
});
const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
}
return result.data;
};

View file

@ -1,30 +1,56 @@
import { got } from 'got';
import nock from 'nock'; import nock from 'nock';
import { VerificationCodeType } from '@logto/connector-kit'; import { VerificationCodeType } from '@logto/connector-kit';
import { emailEndpoint } from './constant.js'; import { emailEndpoint, usageEndpoint } from './constant.js';
import { mockedAccessTokenResponse, mockedConfig } from './mock.js'; import createConnector from './index.js';
const { jest } = import.meta; const { jest } = import.meta;
const getConfig = jest.fn().mockResolvedValue(mockedConfig); const endpoint = 'http://localhost:3003';
const { default: createConnector } = await import('./index.js'); const api = got.extend({ prefixUrl: endpoint });
const dropLeadingSlash = (path: string) => path.replace(/^\//, '');
const buildUrl = (path: string, endpoint: string) => new URL(`${endpoint}/api${path}`);
const getConfig = jest.fn().mockResolvedValue({});
const getCloudServiceClient = jest.fn().mockResolvedValue({
post: async (path: string, payload: { body: unknown }) => {
return api(dropLeadingSlash(path), {
method: 'POST',
json: payload.body,
});
},
get: async (path: string, payload: { search: Record<string, string> }) => {
return api(dropLeadingSlash(path), {
method: 'GET',
searchParams: payload.search,
}).json<{ count: number }>();
},
});
describe('sendMessage()', () => { describe('sendMessage()', () => {
beforeAll(() => {
nock(mockedConfig.tokenEndpoint).post('').reply(200, JSON.stringify(mockedAccessTokenResponse));
});
it('should send message successfully', async () => { it('should send message successfully', async () => {
nock(mockedConfig.endpoint).post(emailEndpoint).reply(200); const url = buildUrl(emailEndpoint, endpoint);
const connector = await createConnector({ getConfig }); nock(url.origin).post(url.pathname).reply(204);
const { sendMessage } = await createConnector({ getConfig, getCloudServiceClient });
await expect( await expect(
connector.sendMessage({ sendMessage({
to: 'wangsijie94@gmail.com', to: 'wangsijie94@gmail.com',
type: VerificationCodeType.SignIn, type: VerificationCodeType.SignIn,
payload: { code: '1234' }, payload: { code: '1234' },
}) })
).resolves.not.toThrow(); ).resolves.not.toThrow();
}); });
it('should get usage successfully', async () => {
const date = new Date();
const url = buildUrl(usageEndpoint, endpoint);
nock(url.origin).get(url.pathname).query({ from: date.toISOString() }).reply(200, { count: 1 });
const connector = await createConnector({ getConfig, getCloudServiceClient });
expect(connector.getUsage).toBeDefined();
const usage = await connector.getUsage!(date);
expect(usage).toEqual(1);
});
}); });

View file

@ -1,10 +1,10 @@
import { assert } from '@silverhand/essentials'; import { assert, conditional } from '@silverhand/essentials';
import { HTTPError, got } from 'got'; import { HTTPError } from 'got';
import { z } from 'zod';
import type { import type {
CreateConnector, CreateConnector,
EmailConnector, EmailConnector,
GetCloudServiceClient,
GetConnectorConfig, GetConnectorConfig,
GetUsageFunction, GetUsageFunction,
SendMessageFunction, SendMessageFunction,
@ -14,53 +14,28 @@ import {
validateConfig, validateConfig,
ConnectorError, ConnectorError,
ConnectorErrorCodes, ConnectorErrorCodes,
parseJson,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { defaultMetadata, defaultTimeout, emailEndpoint, usageEndpoint } from './constant.js'; import { defaultMetadata, emailEndpoint, usageEndpoint } from './constant.js';
import { grantAccessToken } from './grant-access-token.js';
import { logtoEmailConfigGuard } from './types.js'; import { logtoEmailConfigGuard } from './types.js';
const sendMessage = const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction => (getConfig: GetConnectorConfig, getClient?: GetCloudServiceClient): SendMessageFunction =>
async (data, inputConfig) => { async (data, inputConfig) => {
const config = inputConfig ?? (await getConfig(defaultMetadata.id)); const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig(config, logtoEmailConfigGuard); validateConfig(config, logtoEmailConfigGuard);
const { const { companyInformation, senderName, appLogo } = config;
endpoint,
tokenEndpoint,
appId,
appSecret,
resource,
companyInformation,
senderName,
appLogo,
} = config;
const { to, type, payload } = data; const { to, type, payload } = data;
assert( assert(getClient, new ConnectorError(ConnectorErrorCodes.NotImplemented));
endpoint && tokenEndpoint && resource && appId && appSecret, const client = await getClient();
new ConnectorError(ConnectorErrorCodes.InvalidConfig)
);
const accessTokenResponse = await grantAccessToken({
tokenEndpoint,
resource,
appId,
appSecret,
});
try { try {
await got.post({ await client.post(`/api${emailEndpoint}`, {
url: `${endpoint}${emailEndpoint}`, body: {
headers: {
Authorization: `${accessTokenResponse.token_type} ${accessTokenResponse.access_token}`,
},
json: {
data: { to, type, payload: { ...payload, senderName, companyInformation, appLogo } }, data: { to, type, payload: { ...payload, senderName, companyInformation, appLogo } },
}, },
timeout: { request: defaultTimeout },
}); });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
@ -72,46 +47,30 @@ const sendMessage =
}; };
const getUsage = const getUsage =
(getConfig: GetConnectorConfig): GetUsageFunction => (getConfig: GetConnectorConfig, getClient?: GetCloudServiceClient): GetUsageFunction =>
async (startFrom?: Date) => { async (startFrom?: Date) => {
const config = await getConfig(defaultMetadata.id); const config = await getConfig(defaultMetadata.id);
validateConfig(config, logtoEmailConfigGuard); validateConfig(config, logtoEmailConfigGuard);
const { endpoint, tokenEndpoint, appId, appSecret, resource } = config; assert(getClient, new ConnectorError(ConnectorErrorCodes.NotImplemented));
const client = await getClient();
assert( const { count } = await client.get(`/api${usageEndpoint}`, {
endpoint && tokenEndpoint && resource && appId && appSecret, search: conditional(startFrom && { from: startFrom.toISOString() }) ?? {},
new ConnectorError(ConnectorErrorCodes.InvalidConfig)
);
const accessTokenResponse = await grantAccessToken({
tokenEndpoint,
resource,
appId,
appSecret,
}); });
return count;
const httpResponse = await got.get({
url: `${endpoint}${usageEndpoint}`,
headers: {
Authorization: `${accessTokenResponse.token_type} ${accessTokenResponse.access_token}`,
},
timeout: { request: defaultTimeout },
searchParams: {
from: startFrom?.toISOString(),
},
});
return z.object({ count: z.number() }).parse(parseJson(httpResponse.body)).count;
}; };
const createLogtoEmailConnector: CreateConnector<EmailConnector> = async ({ getConfig }) => { const createLogtoEmailConnector: CreateConnector<EmailConnector> = async ({
getConfig,
getCloudServiceClient: getClient,
}) => {
return { return {
metadata: defaultMetadata, metadata: defaultMetadata,
type: ConnectorType.Email, type: ConnectorType.Email,
configGuard: logtoEmailConfigGuard, configGuard: logtoEmailConfigGuard,
sendMessage: sendMessage(getConfig), sendMessage: sendMessage(getConfig, getClient),
getUsage: getUsage(getConfig), getUsage: getUsage(getConfig, getClient),
}; };
}; };

View file

@ -1,16 +0,0 @@
import type { AccessTokenResponse } from './types.js';
export const mockedConfig = {
appId: 'mfvnO3josReyBf9zhDnlr',
appSecret: 'lXNWW4wPj0Bq6msjIl6H3',
tokenEndpoint: 'http://localhost:3002/oidc/token',
endpoint: 'http://localhost:3003/api',
resource: 'https://cloud.logto.io/api',
};
export const mockedAccessTokenResponse: AccessTokenResponse = {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
expires_in: 3600,
};

View file

@ -1,5 +1,5 @@
import type router from '@logto/cloud/routes'; import type router from '@logto/cloud/routes';
import { cloudConnectionDataGuard } from '@logto/schemas'; import { cloudConnectionDataGuard, CloudScope } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials'; import { appendPath } from '@silverhand/essentials';
import Client from '@withtyped/client'; import Client from '@withtyped/client';
import { got } from 'got'; import { got } from 'got';
@ -24,7 +24,11 @@ const accessTokenResponseGuard = z.object({
scope: z.string().optional(), scope: z.string().optional(),
}); });
const scopes: string[] = []; /**
* The scope here can be empty and still work, because the cloud API requests made using this client do not rely on scope verification.
* The `CloudScope.SendEmail` is added for now because it needs to call the cloud email service API.
*/
const scopes: string[] = [CloudScope.SendEmail];
const accessTokenExpirationMargin = 60; const accessTokenExpirationMargin = 60;
/** The library for connecting to Logto Cloud service. */ /** The library for connecting to Logto Cloud service. */

View file

@ -1,9 +1,10 @@
import type { Connector } from '@logto/schemas'; import type { Connector } from '@logto/schemas';
import { mockGetCloudConnectionData } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { MockQueries } from '#src/test-utils/tenant.js'; import { MockQueries } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const connectors: Connector[] = [ const connectors: Connector[] = [
{ {
tenantId: 'fake_tenant', tenantId: 'fake_tenant',
@ -19,7 +20,7 @@ const connectors: Connector[] = [
const { createConnectorLibrary } = await import('./connector.js'); const { createConnectorLibrary } = await import('./connector.js');
const { getConnectorConfig } = createConnectorLibrary( const { getConnectorConfig } = createConnectorLibrary(
new MockQueries({ connectors: { findAllConnectors: async () => connectors } }), new MockQueries({ connectors: { findAllConnectors: async () => connectors } }),
{ getCloudConnectionData: mockGetCloudConnectionData } { getClient: jest.fn() }
); );
it('getConnectorConfig() should return right config', async () => { it('getConnectorConfig() should return right config', async () => {

View file

@ -1,7 +1,7 @@
import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
import type { AllConnector } from '@logto/connector-kit'; import type { AllConnector } from '@logto/connector-kit';
import { validateConfig, ServiceConnector } from '@logto/connector-kit'; import { validateConfig, ServiceConnector } from '@logto/connector-kit';
import { pick, trySafe } from '@silverhand/essentials'; import { conditional, pick, trySafe } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
@ -15,10 +15,10 @@ export type ConnectorLibrary = ReturnType<typeof createConnectorLibrary>;
export const createConnectorLibrary = ( export const createConnectorLibrary = (
queries: Queries, queries: Queries,
cloudConnection: Pick<CloudConnectionLibrary, 'getCloudConnectionData'> cloudConnection: Pick<CloudConnectionLibrary, 'getClient'>
) => { ) => {
const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors; const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors;
const { getCloudConnectionData } = cloudConnection; const { getClient } = cloudConnection;
const getConnectorConfig = async (id: string): Promise<unknown> => { const getConnectorConfig = async (id: string): Promise<unknown> => {
const connectors = await findAllConnectors(); const connectors = await findAllConnectors();
@ -26,10 +26,6 @@ export const createConnectorLibrary = (
assertThat(connector, new RequestError({ code: 'entity.not_found', id, status: 404 })); assertThat(connector, new RequestError({ code: 'entity.not_found', id, status: 404 }));
if (ServiceConnector.Email === connector.connectorId) {
return { ...connector.config, ...(await getCloudConnectionData()) };
}
return connector.config; return connector.config;
}; };
@ -81,7 +77,8 @@ export const createConnectorLibrary = (
try { try {
const { rawConnector, rawMetadata } = await buildRawConnector( const { rawConnector, rawMetadata } = await buildRawConnector(
connectorFactory, connectorFactory,
async () => getConnectorConfig(id) async () => getConnectorConfig(id),
conditional(connectorFactory.metadata.id === ServiceConnector.Email && getClient)
); );
const connector: AllConnector = { const connector: AllConnector = {
@ -123,7 +120,6 @@ export const createConnectorLibrary = (
}; };
return { return {
getCloudConnectionData,
getConnectorConfig, getConnectorConfig,
getLogtoConnectors, getLogtoConnectors,
getLogtoConnectorsWellKnown, getLogtoConnectorsWellKnown,

View file

@ -7,7 +7,6 @@ import {
socialTarget02, socialTarget02,
mockSignInExperience, mockSignInExperience,
mockSocialConnectors, mockSocialConnectors,
mockGetCloudConnectionData,
} from '#src/__mocks__/index.js'; } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -39,7 +38,7 @@ const queries = new MockQueries({
signInExperiences, signInExperiences,
}); });
const connectorLibrary = createConnectorLibrary(queries, { const connectorLibrary = createConnectorLibrary(queries, {
getCloudConnectionData: mockGetCloudConnectionData, getClient: jest.fn(),
}); });
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');

View file

@ -1,4 +1,4 @@
import { buildRawConnector } from '@logto/cli/lib/connector/index.js'; import { buildRawConnector, notImplemented } from '@logto/cli/lib/connector/index.js';
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js'; import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import { import {
type SmsConnector, type SmsConnector,
@ -9,6 +9,7 @@ import {
import { ServiceConnector } from '@logto/connector-kit'; import { ServiceConnector } from '@logto/connector-kit';
import { phoneRegEx, emailRegEx } from '@logto/core-kit'; import { phoneRegEx, emailRegEx } from '@logto/core-kit';
import { jsonObjectGuard, ConnectorType } from '@logto/schemas'; import { jsonObjectGuard, ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { string, object } from 'zod'; import { string, object } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -21,7 +22,7 @@ import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function connectorConfigTestingRoutes<T extends AuthedRouter>( export default function connectorConfigTestingRoutes<T extends AuthedRouter>(
...[router, { cloudConnection }]: RouterInitArgs<T> ...[router, { cloudConnection }]: RouterInitArgs<T>
) { ) {
const { getCloudConnectionData } = cloudConnection; const { getClient } = cloudConnection;
router.post( router.post(
'/connectors/:factoryId/test', '/connectors/:factoryId/test',
@ -38,7 +39,7 @@ export default function connectorConfigTestingRoutes<T extends AuthedRouter>(
params: { factoryId }, params: { factoryId },
body, body,
} = ctx.guard; } = ctx.guard;
const { phone, email, config: originalConfig } = body; const { phone, email, config } = body;
const subject = phone ?? email; const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' })); assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
@ -65,17 +66,12 @@ export default function connectorConfigTestingRoutes<T extends AuthedRouter>(
const { const {
rawConnector: { sendMessage }, rawConnector: { sendMessage },
} = await buildRawConnector<SmsConnector | EmailConnector>(connectorFactory); } = await buildRawConnector<SmsConnector | EmailConnector>(
connectorFactory,
notImplemented,
conditional(ServiceConnector.Email === connectorFactory.metadata.id && getClient)
);
/**
* Should manually attach cloud connection data to logto email connector since we directly use
* this `config` to test the `sendMessage` method.
* Logto email connector will no longer save cloud connection data to its `config` after this change.
*/
const config =
ServiceConnector.Email === connectorFactory.metadata.id
? { ...(await getCloudConnectionData()), ...originalConfig }
: originalConfig;
await sendMessage( await sendMessage(
{ {
to: subject, to: subject,

View file

@ -37,12 +37,14 @@
}, },
"dependencies": { "dependencies": {
"@logto/language-kit": "workspace:^1.0.0", "@logto/language-kit": "workspace:^1.0.0",
"@silverhand/essentials": "^2.5.0" "@silverhand/essentials": "^2.5.0",
"@withtyped/client": "^0.7.21"
}, },
"optionalDependencies": { "optionalDependencies": {
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@logto/cloud": "0.2.5-33a6965",
"@jest/types": "^29.0.3", "@jest/types": "^29.0.3",
"@silverhand/eslint-config": "4.0.1", "@silverhand/eslint-config": "4.0.1",
"@silverhand/ts-config": "4.0.0", "@silverhand/ts-config": "4.0.0",

View file

@ -1,5 +1,7 @@
import type router from '@logto/cloud/routes';
import type { LanguageTag } from '@logto/language-kit'; import type { LanguageTag } from '@logto/language-kit';
import { isLanguageTag } from '@logto/language-kit'; import { isLanguageTag } from '@logto/language-kit';
import type Client from '@withtyped/client';
import type { ZodType } from 'zod'; import type { ZodType } from 'zod';
import { z } from 'zod'; import { z } from 'zod';
@ -199,10 +201,13 @@ export type BaseConnector<Type extends ConnectorType> = {
export type CreateConnector<T extends AllConnector> = (options: { export type CreateConnector<T extends AllConnector> = (options: {
getConfig: GetConnectorConfig; getConfig: GetConnectorConfig;
getCloudServiceClient?: GetCloudServiceClient;
}) => Promise<T>; }) => Promise<T>;
export type GetConnectorConfig = (id: string) => Promise<unknown>; export type GetConnectorConfig = (id: string) => Promise<unknown>;
export type GetCloudServiceClient = () => Promise<Client<typeof router>>;
export type AllConnector = SmsConnector | EmailConnector | SocialConnector; export type AllConnector = SmsConnector | EmailConnector | SocialConnector;
// MARK: SMS + Email connector // MARK: SMS + Email connector

View file

@ -3747,6 +3747,9 @@ importers:
'@silverhand/essentials': '@silverhand/essentials':
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
'@withtyped/client':
specifier: ^0.7.21
version: 0.7.21(zod@3.20.2)
optionalDependencies: optionalDependencies:
zod: zod:
specifier: ^3.20.2 specifier: ^3.20.2
@ -3755,6 +3758,9 @@ importers:
'@jest/types': '@jest/types':
specifier: ^29.0.3 specifier: ^29.0.3
version: 29.1.2 version: 29.1.2
'@logto/cloud':
specifier: 0.2.5-33a6965
version: 0.2.5-33a6965(zod@3.20.2)
'@silverhand/eslint-config': '@silverhand/eslint-config':
specifier: 4.0.1 specifier: 4.0.1
version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2) version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2)