0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00
logto/packages/core/src/libraries/cloud-connection.ts
Darcy Ye c3bec6803d
Revert "Revert "refactor: add report:subscription:updates Cloud scope"" (#6415)
* Revert "Revert "refactor: add `report:subscription:updates` Cloud scope" (#6412)"

This reverts commit ebc04a283d.

* chore: update alteration script ts

* chore: rebase and update alteration timestamp
2024-08-21 08:51:03 +00:00

131 lines
4.4 KiB
TypeScript

import type router from '@logto/cloud/routes';
import { cloudConnectionDataGuard, CloudScope } from '@logto/schemas';
import { formUrlEncodedHeaders } from '@logto/shared';
import { appendPath } from '@silverhand/essentials';
import Client from '@withtyped/client';
import ky from 'ky';
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 const cloudConnectionGuard = cloudConnectionDataGuard.extend({
tokenEndpoint: z.string(),
endpoint: z.string(),
});
export type CloudConnection = z.infer<typeof cloudConnectionGuard>;
const accessTokenResponseGuard = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.string(),
scope: z.string().optional(),
});
/**
* 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.
* The `CloudScope.FetchCustomJwt` is added for now because it needs to call the cloud custom JWT service API.
* The `CloudScope.ReportSubscriptionUpdates` is added since we need to report subscription updates to the cloud.
*/
const scopes: string[] = [
CloudScope.SendEmail,
CloudScope.FetchCustomJwt,
CloudScope.ReportSubscriptionUpdates,
];
const accessTokenExpirationMargin = 60;
/** The library for connecting to Logto Cloud service. */
export class CloudConnectionLibrary {
private client?: Client<typeof router>;
private accessTokenCache?: { expiresAt: number; accessToken: string };
constructor(private readonly logtoConfigs: LogtoConfigLibrary) {}
public getCloudConnectionData = async (): Promise<CloudConnection> => {
const { getCloudConnectionData: getCloudServiceM2mCredentials } = this.logtoConfigs;
const credentials = await getCloudServiceM2mCredentials();
const { cloudUrlSet, adminUrlSet } = EnvSet.values;
return {
...credentials,
tokenEndpoint: appendPath(adminUrlSet.endpoint, 'oidc/token').toString(),
endpoint: appendPath(cloudUrlSet.endpoint, 'api').toString(),
};
};
/**
* Get the access token for the Cloud service in the following steps:
*
* 1. If the access token is cached and not expired, return it.
* 2. Otherwise, get a new access token from the Cloud service via client
* credentials flow and cache it.
* 3. If the request fails, throw an error.
*
* @returns The access token for the Cloud service.
*/
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 text = await ky
.post(tokenEndpoint, {
headers: {
...formUrlEncodedHeaders,
Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
resource,
scope: scopes.join(' '),
}),
})
.text();
const result = accessTokenResponseGuard.safeParse(safeParseJson(text));
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;
};
/**
* Get a withtyped client for the Cloud service. It is typed with the router
* defined in @logto/cloud/routes.
*/
public getClient = async (): Promise<Client<typeof router>> => {
if (!this.client) {
const { endpoint } = await this.getCloudConnectionData();
this.client = new Client<typeof router>({
// TODO @sijie @darcy remove the 'api' appending in getCloudConnectionData()
baseUrl: endpoint.replace('/api', ''),
headers: async () => {
return { Authorization: `Bearer ${await this.getAccessToken()}` };
},
});
}
return this.client;
};
}
export const createCloudConnectionLibrary = (logtoConfigs: LogtoConfigLibrary) => {
return new CloudConnectionLibrary(logtoConfigs);
};