0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): add cloud client (#4127)

This commit is contained in:
wangsijie 2023-07-07 16:20:11 +08:00 committed by GitHub
parent 5ccdd7f31a
commit 7eaed24b95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 175 additions and 18 deletions

View file

@ -41,6 +41,7 @@
"@logto/shared": "workspace:^2.0.0",
"@logto/ui": "workspace:*",
"@silverhand/essentials": "^2.5.0",
"@withtyped/client": "^0.7.11",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",
"date-fns": "^2.29.3",
@ -80,6 +81,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@logto/cloud": "0.2.5-1a68662",
"@silverhand/eslint-config": "3.0.1",
"@silverhand/ts-config": "3.0.0",
"@types/debug": "^4.1.7",
@ -102,6 +104,7 @@
"jest": "^29.5.0",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0",
"nock": "^13.3.1",
"node-mocks-http": "^1.12.1",
"nodemon": "^2.0.19",
"openapi-types": "^12.0.0",

View file

@ -1,11 +1,10 @@
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
export const mockCloudConnectionLibrary: CloudConnectionLibrary = {
getCloudConnectionData: async () => ({
export const mockGetCloudConnectionData: CloudConnectionLibrary['getCloudConnectionData'] =
async () => ({
resource: 'https://logto.dev',
appId: 'appId',
appSecret: 'appSecret',
endpoint: 'https://logto.dev/api',
tokenEndpoint: 'https://logto.dev/oidc/token',
}),
};
});

View file

@ -0,0 +1,59 @@
import { GlobalValues } from '@logto/shared';
import { createMockUtils } from '@logto/shared/esm';
import nock from 'nock';
import { type LogtoConfigLibrary } from './logto-config.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const adminEndpoint = 'http://mock.com';
const mockAccessToken = 'mockAccessToken';
await mockEsmWithActual('#src/env-set/index.js', () => ({
EnvSet: {
get values() {
const values = new GlobalValues();
return {
...values,
adminUrlSet: {
...values.adminUrlSet,
endpoint: new URL(adminEndpoint),
},
};
},
},
}));
const { createCloudConnectionLibrary } = await import('./cloud-connection.js');
const logtoConfigs: LogtoConfigLibrary = {
getCloudConnectionData: jest.fn().mockResolvedValue({
appId: 'appId',
appSecret: 'appSecret',
resource: 'resource',
}),
getOidcConfigs: jest.fn(),
};
describe('getAccessToken()', () => {
const { getAccessToken } = createCloudConnectionLibrary(logtoConfigs);
it('should get access token and cached', async () => {
nock(adminEndpoint).post('/oidc/token').reply(200, {
access_token: mockAccessToken,
expires_in: 3600,
token_type: 'Bearer',
});
const token = await getAccessToken();
expect(token).toBe(mockAccessToken);
nock(adminEndpoint).post('/oidc/token').reply(200, {
access_token: 'anotherAccessToken',
expires_in: 3600,
token_type: 'Bearer',
});
const cachedToken = await getAccessToken();
expect(cachedToken).toBe(mockAccessToken);
});
});

View file

@ -1,26 +1,40 @@
import type router from '@logto/cloud/routes';
import { cloudConnectionDataGuard } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import Client from '@withtyped/client';
import { got } from 'got';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { safeParseJson } from '#src/utils/json.js';
import { type LogtoConfigLibrary } from './logto-config.js';
export type CloudConnectionLibrary = ReturnType<typeof createCloudConnectionLibrary>;
// eslint-disable-next-line import/no-unused-modules
export const cloudConnectionGuard = cloudConnectionDataGuard.extend({
tokenEndpoint: z.string(),
endpoint: z.string(),
});
// eslint-disable-next-line import/no-unused-modules
export type CloudConnection = z.infer<typeof cloudConnectionGuard>;
export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) => {
const { getCloudConnectionData: getCloudServiceM2mCredentials } = logtoConfigs;
const accessTokenResponseGuard = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.string(),
scope: z.string().optional(),
});
const getCloudConnectionData = async (): Promise<CloudConnection> => {
const scopes: string[] = [];
const accessTokenExpirationMargin = 60;
export class CloudConnectionLibrary {
private client?: Client<typeof router>;
private accessTokenCache?: { expiresAt: number; accessToken: string };
constructor(private readonly logtoConfigs: LogtoConfigLibrary) {}
async getCloudConnectionData(): Promise<CloudConnection> {
const { getCloudConnectionData: getCloudServiceM2mCredentials } = this.logtoConfigs;
const credentials = await getCloudServiceM2mCredentials();
const { cloudUrlSet, adminUrlSet } = EnvSet.values;
return {
@ -28,7 +42,62 @@ export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) =
tokenEndpoint: appendPath(adminUrlSet.endpoint, 'oidc/token').toString(),
endpoint: appendPath(cloudUrlSet.endpoint, 'api').toString(),
};
}
public getAccessToken = async (): Promise<string> => {
if (this.accessTokenCache) {
const { expiresAt, accessToken } = this.accessTokenCache;
if (expiresAt > Date.now() / 1000 + accessTokenExpirationMargin) {
return accessToken;
}
}
const { tokenEndpoint, appId, appSecret, resource } = await this.getCloudConnectionData();
const httpResponse = await got.post({
url: tokenEndpoint,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString('base64')}`,
},
form: {
grant_type: 'client_credentials',
resource,
scope: scopes.join(' '),
},
});
const result = accessTokenResponseGuard.safeParse(safeParseJson(httpResponse.body));
if (!result.success) {
throw new Error('Unable to get access token for Cloud service');
}
this.accessTokenCache = {
expiresAt: Date.now() / 1000 + result.data.expires_in,
accessToken: result.data.access_token,
};
return result.data.access_token;
};
return { getCloudConnectionData };
public getClient = async (): Promise<Client<typeof router>> => {
if (!this.client) {
const { endpoint } = await this.getCloudConnectionData();
this.client = new Client<typeof router>({
baseUrl: endpoint,
headers: async () => {
return { Authorization: `Bearer ${await this.getAccessToken()}` };
},
});
}
return this.client;
};
}
export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) => {
return new CloudConnectionLibrary(logtoConfigs);
};

View file

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

View file

@ -15,7 +15,7 @@ export type ConnectorLibrary = ReturnType<typeof createConnectorLibrary>;
export const createConnectorLibrary = (
queries: Queries,
cloudConnection: CloudConnectionLibrary
cloudConnection: Pick<CloudConnectionLibrary, 'getCloudConnectionData'>
) => {
const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors;
const { getCloudConnectionData } = cloudConnection;

View file

@ -3,11 +3,11 @@ import { builtInLanguages } from '@logto/phrases-ui';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import {
mockCloudConnectionLibrary,
socialTarget01,
socialTarget02,
mockSignInExperience,
mockSocialConnectors,
mockGetCloudConnectionData,
} from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -38,7 +38,9 @@ const queries = new MockQueries({
customPhrases,
signInExperiences,
});
const connectorLibrary = createConnectorLibrary(queries, mockCloudConnectionLibrary);
const connectorLibrary = createConnectorLibrary(queries, {
getCloudConnectionData: mockGetCloudConnectionData,
});
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
const { createSignInExperienceLibrary } = await import('./index.js');

View file

@ -0,0 +1,5 @@
import { trySafe } from '@silverhand/essentials';
export const safeParseJson = (jsonString: string): unknown =>
// eslint-disable-next-line no-restricted-syntax
trySafe(() => JSON.parse(jsonString) as unknown);

View file

@ -3078,6 +3078,9 @@ importers:
'@silverhand/essentials':
specifier: ^2.5.0
version: 2.5.0
'@withtyped/client':
specifier: ^0.7.11
version: 0.7.11(zod@3.20.2)
chalk:
specifier: ^5.0.0
version: 5.1.2
@ -3190,6 +3193,9 @@ importers:
specifier: ^3.20.2
version: 3.20.2
devDependencies:
'@logto/cloud':
specifier: 0.2.5-1a68662
version: 0.2.5-1a68662(zod@3.20.2)
'@silverhand/eslint-config':
specifier: 3.0.1
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
@ -3256,6 +3262,9 @@ importers:
lint-staged:
specifier: ^13.0.0
version: 13.0.0
nock:
specifier: ^13.3.1
version: 13.3.1
node-mocks-http:
specifier: ^1.12.1
version: 1.12.1
@ -9791,7 +9800,6 @@ packages:
'@withtyped/shared': 0.2.1
transitivePeerDependencies:
- zod
dev: true
/@withtyped/server@0.12.0(zod@3.20.2):
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
@ -15899,6 +15907,18 @@ packages:
- supports-color
dev: true
/nock@13.3.1:
resolution: {integrity: sha512-vHnopocZuI93p2ccivFyGuUfzjq2fxNyNurp7816mlT5V5HF4SzXu8lvLrVzBbNqzs+ODooZ6OksuSUNM7Njkw==}
engines: {node: '>= 10.13'}
dependencies:
debug: 4.3.4
json-stringify-safe: 5.0.1
lodash: 4.17.21
propagate: 2.0.1
transitivePeerDependencies:
- supports-color
dev: true
/node-addon-api@3.2.1:
resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
dev: true