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:
parent
5ccdd7f31a
commit
7eaed24b95
9 changed files with 175 additions and 18 deletions
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
59
packages/core/src/libraries/cloud-connection.test.ts
Normal file
59
packages/core/src/libraries/cloud-connection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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 { getCloudConnectionData };
|
||||
return result.data.access_token;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
|
|
5
packages/core/src/utils/json.ts
Normal file
5
packages/core/src/utils/json.ts
Normal 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);
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue