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/shared": "workspace:^2.0.0",
|
||||||
"@logto/ui": "workspace:*",
|
"@logto/ui": "workspace:*",
|
||||||
"@silverhand/essentials": "^2.5.0",
|
"@silverhand/essentials": "^2.5.0",
|
||||||
|
"@withtyped/client": "^0.7.11",
|
||||||
"chalk": "^5.0.0",
|
"chalk": "^5.0.0",
|
||||||
"clean-deep": "^3.4.0",
|
"clean-deep": "^3.4.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@logto/cloud": "0.2.5-1a68662",
|
||||||
"@silverhand/eslint-config": "3.0.1",
|
"@silverhand/eslint-config": "3.0.1",
|
||||||
"@silverhand/ts-config": "3.0.0",
|
"@silverhand/ts-config": "3.0.0",
|
||||||
"@types/debug": "^4.1.7",
|
"@types/debug": "^4.1.7",
|
||||||
|
@ -102,6 +104,7 @@
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-matcher-specific-error": "^1.0.0",
|
"jest-matcher-specific-error": "^1.0.0",
|
||||||
"lint-staged": "^13.0.0",
|
"lint-staged": "^13.0.0",
|
||||||
|
"nock": "^13.3.1",
|
||||||
"node-mocks-http": "^1.12.1",
|
"node-mocks-http": "^1.12.1",
|
||||||
"nodemon": "^2.0.19",
|
"nodemon": "^2.0.19",
|
||||||
"openapi-types": "^12.0.0",
|
"openapi-types": "^12.0.0",
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||||
|
|
||||||
export const mockCloudConnectionLibrary: CloudConnectionLibrary = {
|
export const mockGetCloudConnectionData: CloudConnectionLibrary['getCloudConnectionData'] =
|
||||||
getCloudConnectionData: async () => ({
|
async () => ({
|
||||||
resource: 'https://logto.dev',
|
resource: 'https://logto.dev',
|
||||||
appId: 'appId',
|
appId: 'appId',
|
||||||
appSecret: 'appSecret',
|
appSecret: 'appSecret',
|
||||||
endpoint: 'https://logto.dev/api',
|
endpoint: 'https://logto.dev/api',
|
||||||
tokenEndpoint: 'https://logto.dev/oidc/token',
|
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 { cloudConnectionDataGuard } from '@logto/schemas';
|
||||||
import { appendPath } from '@silverhand/essentials';
|
import { appendPath } from '@silverhand/essentials';
|
||||||
|
import Client from '@withtyped/client';
|
||||||
|
import { got } from 'got';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
import { safeParseJson } from '#src/utils/json.js';
|
||||||
|
|
||||||
import { type LogtoConfigLibrary } from './logto-config.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({
|
export const cloudConnectionGuard = cloudConnectionDataGuard.extend({
|
||||||
tokenEndpoint: z.string(),
|
tokenEndpoint: z.string(),
|
||||||
endpoint: z.string(),
|
endpoint: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unused-modules
|
|
||||||
export type CloudConnection = z.infer<typeof cloudConnectionGuard>;
|
export type CloudConnection = z.infer<typeof cloudConnectionGuard>;
|
||||||
|
|
||||||
export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) => {
|
const accessTokenResponseGuard = z.object({
|
||||||
const { getCloudConnectionData: getCloudServiceM2mCredentials } = logtoConfigs;
|
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 credentials = await getCloudServiceM2mCredentials();
|
||||||
const { cloudUrlSet, adminUrlSet } = EnvSet.values;
|
const { cloudUrlSet, adminUrlSet } = EnvSet.values;
|
||||||
return {
|
return {
|
||||||
|
@ -28,7 +42,62 @@ export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) =
|
||||||
tokenEndpoint: appendPath(adminUrlSet.endpoint, 'oidc/token').toString(),
|
tokenEndpoint: appendPath(adminUrlSet.endpoint, 'oidc/token').toString(),
|
||||||
endpoint: appendPath(cloudUrlSet.endpoint, 'api').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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Connector } from '@logto/schemas';
|
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 RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||||
|
|
||||||
|
@ -19,7 +19,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 } }),
|
||||||
mockCloudConnectionLibrary
|
{ getCloudConnectionData: mockGetCloudConnectionData }
|
||||||
);
|
);
|
||||||
|
|
||||||
it('getConnectorConfig() should return right config', async () => {
|
it('getConnectorConfig() should return right config', async () => {
|
||||||
|
|
|
@ -15,7 +15,7 @@ export type ConnectorLibrary = ReturnType<typeof createConnectorLibrary>;
|
||||||
|
|
||||||
export const createConnectorLibrary = (
|
export const createConnectorLibrary = (
|
||||||
queries: Queries,
|
queries: Queries,
|
||||||
cloudConnection: CloudConnectionLibrary
|
cloudConnection: Pick<CloudConnectionLibrary, 'getCloudConnectionData'>
|
||||||
) => {
|
) => {
|
||||||
const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors;
|
const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors;
|
||||||
const { getCloudConnectionData } = cloudConnection;
|
const { getCloudConnectionData } = cloudConnection;
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { builtInLanguages } from '@logto/phrases-ui';
|
||||||
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
|
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mockCloudConnectionLibrary,
|
|
||||||
socialTarget01,
|
socialTarget01,
|
||||||
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';
|
||||||
|
|
||||||
|
@ -38,7 +38,9 @@ const queries = new MockQueries({
|
||||||
customPhrases,
|
customPhrases,
|
||||||
signInExperiences,
|
signInExperiences,
|
||||||
});
|
});
|
||||||
const connectorLibrary = createConnectorLibrary(queries, mockCloudConnectionLibrary);
|
const connectorLibrary = createConnectorLibrary(queries, {
|
||||||
|
getCloudConnectionData: mockGetCloudConnectionData,
|
||||||
|
});
|
||||||
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
||||||
|
|
||||||
const { createSignInExperienceLibrary } = await import('./index.js');
|
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':
|
'@silverhand/essentials':
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
|
'@withtyped/client':
|
||||||
|
specifier: ^0.7.11
|
||||||
|
version: 0.7.11(zod@3.20.2)
|
||||||
chalk:
|
chalk:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.1.2
|
version: 5.1.2
|
||||||
|
@ -3190,6 +3193,9 @@ importers:
|
||||||
specifier: ^3.20.2
|
specifier: ^3.20.2
|
||||||
version: 3.20.2
|
version: 3.20.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@logto/cloud':
|
||||||
|
specifier: 0.2.5-1a68662
|
||||||
|
version: 0.2.5-1a68662(zod@3.20.2)
|
||||||
'@silverhand/eslint-config':
|
'@silverhand/eslint-config':
|
||||||
specifier: 3.0.1
|
specifier: 3.0.1
|
||||||
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
|
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
|
||||||
|
@ -3256,6 +3262,9 @@ importers:
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0
|
version: 13.0.0
|
||||||
|
nock:
|
||||||
|
specifier: ^13.3.1
|
||||||
|
version: 13.3.1
|
||||||
node-mocks-http:
|
node-mocks-http:
|
||||||
specifier: ^1.12.1
|
specifier: ^1.12.1
|
||||||
version: 1.12.1
|
version: 1.12.1
|
||||||
|
@ -9791,7 +9800,6 @@ packages:
|
||||||
'@withtyped/shared': 0.2.1
|
'@withtyped/shared': 0.2.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- zod
|
- zod
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@withtyped/server@0.12.0(zod@3.20.2):
|
/@withtyped/server@0.12.0(zod@3.20.2):
|
||||||
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
|
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
|
||||||
|
@ -15899,6 +15907,18 @@ packages:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/node-addon-api@3.2.1:
|
||||||
resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
|
resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
Loading…
Reference in a new issue