diff --git a/packages/core/package.json b/packages/core/package.json index 92e1a1f24..89e886a86 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/__mocks__/cloud-connection.ts b/packages/core/src/__mocks__/cloud-connection.ts index c021ac968..0cb4aee43 100644 --- a/packages/core/src/__mocks__/cloud-connection.ts +++ b/packages/core/src/__mocks__/cloud-connection.ts @@ -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', - }), -}; + }); diff --git a/packages/core/src/libraries/cloud-connection.test.ts b/packages/core/src/libraries/cloud-connection.test.ts new file mode 100644 index 000000000..9895fe9aa --- /dev/null +++ b/packages/core/src/libraries/cloud-connection.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/libraries/cloud-connection.ts b/packages/core/src/libraries/cloud-connection.ts index 79117effc..fd61110f0 100644 --- a/packages/core/src/libraries/cloud-connection.ts +++ b/packages/core/src/libraries/cloud-connection.ts @@ -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; - -// 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; -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 => { +const scopes: string[] = []; +const accessTokenExpirationMargin = 60; + +export class CloudConnectionLibrary { + private client?: Client; + private accessTokenCache?: { expiresAt: number; accessToken: string }; + + constructor(private readonly logtoConfigs: LogtoConfigLibrary) {} + + async getCloudConnectionData(): Promise { + 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 => { + 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> => { + if (!this.client) { + const { endpoint } = await this.getCloudConnectionData(); + + this.client = new Client({ + baseUrl: endpoint, + headers: async () => { + return { Authorization: `Bearer ${await this.getAccessToken()}` }; + }, + }); + } + + return this.client; + }; +} + +export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) => { + return new CloudConnectionLibrary(logtoConfigs); }; diff --git a/packages/core/src/libraries/connector.test.ts b/packages/core/src/libraries/connector.test.ts index 4364f673d..ece7305e3 100644 --- a/packages/core/src/libraries/connector.test.ts +++ b/packages/core/src/libraries/connector.test.ts @@ -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 () => { diff --git a/packages/core/src/libraries/connector.ts b/packages/core/src/libraries/connector.ts index 7b2c0f1ed..c5303a2f4 100644 --- a/packages/core/src/libraries/connector.ts +++ b/packages/core/src/libraries/connector.ts @@ -15,7 +15,7 @@ export type ConnectorLibrary = ReturnType; export const createConnectorLibrary = ( queries: Queries, - cloudConnection: CloudConnectionLibrary + cloudConnection: Pick ) => { const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors; const { getCloudConnectionData } = cloudConnection; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index eda6a216b..a2819ef73 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -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'); diff --git a/packages/core/src/utils/json.ts b/packages/core/src/utils/json.ts new file mode 100644 index 000000000..ad2ab4e81 --- /dev/null +++ b/packages/core/src/utils/json.ts @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e13ef26c..4def15425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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