From 2d0502a427f374dc9f4c3935f1754954b7120b89 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Thu, 1 Aug 2024 12:47:24 +0800 Subject: [PATCH] feat(cli): add cli command to setup custom ui local debugging proxy (#6365) * feat(cli): add proxy * refactor(cli): polish code per comments * refactor(cli): polish code * refactor(cli): support serving static files * chore: add changeset * refactor: polish code * refactor(cli): polish code * refactor(cli): make json parse safer --- .changeset/slow-buses-rhyme.md | 34 +++++ packages/cli/package.json | 2 + packages/cli/src/commands/proxy/index.ts | 106 +++++++++++++++ packages/cli/src/commands/proxy/types.ts | 18 +++ packages/cli/src/commands/proxy/utils.ts | 157 +++++++++++++++++++++++ packages/cli/src/index.ts | 2 + pnpm-lock.yaml | 78 +++++++---- 7 files changed, 369 insertions(+), 28 deletions(-) create mode 100644 .changeset/slow-buses-rhyme.md create mode 100644 packages/cli/src/commands/proxy/index.ts create mode 100644 packages/cli/src/commands/proxy/types.ts create mode 100644 packages/cli/src/commands/proxy/utils.ts diff --git a/.changeset/slow-buses-rhyme.md b/.changeset/slow-buses-rhyme.md new file mode 100644 index 000000000..85443c4b5 --- /dev/null +++ b/.changeset/slow-buses-rhyme.md @@ -0,0 +1,34 @@ +--- +"@logto/cli": minor +--- + +add new cli command to setup proxy for developing and debugging custom ui locally + +This command will establish a proxy tunnel between the following 3 entities together: your Logto cloud auth services, your application, and your custom sign-in UI. + +Assuming you have a custom sign-in page running on `http://localhost:4000`. +Then you can execute the command this way: + +```bash +npm cli proxy --endpoint https://.logto.app --port 9000 --experience-uri http://localhost:4000 +``` + +Or if you don't have your custom UI pages hosted on a dev server, you can use the `--experience-path` option to specify the path to your static files: + +```bash +npm cli proxy --endpoint https://.logto.app --port 9000 --experience-path /path/to/your/custom/ui +``` + +This command also works if you have enabled custom domain in your Logto tenant. E.g.: + +```bash +npm cli proxy --endpoint https://your-custom-domain.com --port 9000 --experience-path /path/to/your/custom/ui +``` + +This should set up the proxy and it will be running on your local machine at `http://localhost:9000/`. + +Finally, run your application and set its Logto endpoint to the proxy address `http://localhost:9000/` instead. + +If all set up correctly, when you click the "sign-in" button in your application, you should be navigated to your custom sign-in page instead of Logto's built-in UI, along with valid session (cookies) that allows you to further interact with Logto experience API. + +Happy coding! diff --git a/packages/cli/package.json b/packages/cli/package.json index 2b402ba10..048805c1d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,7 +56,9 @@ "dotenv": "^16.4.5", "got": "^14.0.0", "hpagent": "^1.2.0", + "http-proxy-middleware": "^3.0.0", "inquirer": "^9.0.0", + "mime": "^4.0.4", "nanoid": "^5.0.1", "ora": "^8.0.1", "p-limit": "^6.0.0", diff --git a/packages/cli/src/commands/proxy/index.ts b/packages/cli/src/commands/proxy/index.ts new file mode 100644 index 000000000..a12428b3e --- /dev/null +++ b/packages/cli/src/commands/proxy/index.ts @@ -0,0 +1,106 @@ +import http from 'node:http'; + +import { isValidUrl } from '@logto/core-kit'; +import { conditional } from '@silverhand/essentials'; +import chalk from 'chalk'; +import type { CommandModule } from 'yargs'; + +import { consoleLog } from '../../utils.js'; + +import { type ProxyCommandArgs } from './types.js'; +import { + checkExperienceInput, + createLogtoResponseHandler, + createProxy, + createStaticFileProxy, + isLogtoRequestPath, +} from './utils.js'; + +const proxy: CommandModule = { + command: ['proxy'], + describe: 'Command for Logto proxy', + builder: (yargs) => + yargs + .options({ + 'experience-uri': { + alias: ['x'], + describe: 'The URI of your custom sign-in experience page.', + type: 'string', + }, + 'experience-path': { + alias: ['xp'], + describe: 'The local folder path of your custom sign-in experience assets.', + type: 'string', + }, + endpoint: { + alias: 'ep', + describe: + 'Logto endpoint URI, which can be found in Logto Console. E.g.: https://.logto.app/', + type: 'string', + }, + port: { + alias: 'p', + describe: 'The port number where the proxy server will be running on. Defaults to 9000.', + type: 'number', + default: 9000, + }, + verbose: { + alias: 'v', + describe: 'Show verbose output.', + type: 'boolean', + default: false, + }, + }) + .global('e'), + handler: async ({ 'experience-uri': url, 'experience-path': path, endpoint, port, verbose }) => { + checkExperienceInput(url, path); + + if (!endpoint || !isValidUrl(endpoint)) { + consoleLog.fatal('A valid Logto endpoint URI must be provided.'); + } + const logtoEndpointUrl = new URL(endpoint); + const proxyUrl = new URL(`http://localhost:${port}`); + + const proxyLogtoRequest = createProxy( + logtoEndpointUrl.href, + async (proxyResponse, request, response) => + createLogtoResponseHandler({ + proxyResponse, + request, + response, + logtoEndpointUrl, + proxyUrl, + verbose, + }) + ); + const proxyExperienceServerRequest = conditional(url && createProxy(url)); + const proxyExperienceStaticFileRequest = conditional(path && createStaticFileProxy(path)); + + const server = http.createServer((request, response) => { + if (verbose) { + consoleLog.info(`Incoming request: ${chalk.blue(request.method, request.url)}`); + } + + // Proxy the requests to Logto endpoint + if (isLogtoRequestPath(request.url)) { + void proxyLogtoRequest(request, response); + return; + } + + if (proxyExperienceServerRequest) { + void proxyExperienceServerRequest(request, response); + return; + } + + if (proxyExperienceStaticFileRequest) { + void proxyExperienceStaticFileRequest(request, response); + } + }); + + server.listen(port, () => { + consoleLog.info(`Proxy server is running on ${chalk.blue(proxyUrl.href)}`); + }); + }, +}; + +export default proxy; diff --git a/packages/cli/src/commands/proxy/types.ts b/packages/cli/src/commands/proxy/types.ts new file mode 100644 index 000000000..af80b4792 --- /dev/null +++ b/packages/cli/src/commands/proxy/types.ts @@ -0,0 +1,18 @@ +import type * as http from 'node:http'; + +export type ProxyCommandArgs = { + 'experience-uri'?: string; + 'experience-path'?: string; + endpoint?: string; + port: number; + verbose: boolean; +}; + +export type ProxyResponseHandler = { + proxyResponse: http.IncomingMessage; + request: http.IncomingMessage; + response: http.ServerResponse; + logtoEndpointUrl: URL; + proxyUrl: URL; + verbose: boolean; +}; diff --git a/packages/cli/src/commands/proxy/utils.ts b/packages/cli/src/commands/proxy/utils.ts new file mode 100644 index 000000000..28283be84 --- /dev/null +++ b/packages/cli/src/commands/proxy/utils.ts @@ -0,0 +1,157 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import type http from 'node:http'; +import path from 'node:path'; + +import { isValidUrl } from '@logto/core-kit'; +import { conditional, trySafe } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; +import { type OnProxyEvent } from 'http-proxy-middleware/dist/types.js'; +import mime from 'mime'; + +import { consoleLog } from '../../utils.js'; + +import { type ProxyResponseHandler } from './types.js'; + +export const createProxy = (targetUrl: string, onProxyResponse?: OnProxyEvent['proxyRes']) => { + const hasResponseHandler = Boolean(onProxyResponse); + return createProxyMiddleware({ + target: targetUrl, + changeOrigin: true, + selfHandleResponse: hasResponseHandler, + ...conditional( + hasResponseHandler && { + on: { + proxyRes: onProxyResponse, + error: (error) => { + consoleLog.error(chalk.red(error)); + }, + }, + } + ), + }); +}; + +const index = 'index.html'; +const indexContentType = 'text/html; charset=utf-8'; +const noCache = 'no-cache, no-store, must-revalidate'; +const maxAgeSevenDays = 'max-age=604_800_000'; + +export const createStaticFileProxy = + (staticPath: string) => async (request: http.IncomingMessage, response: http.ServerResponse) => { + if (!request.url) { + response.writeHead(400).end(); + return; + } + + if (request.method === 'HEAD' || request.method === 'GET') { + const fallBackToIndex = !isFileAssetPath(request.url); + const requestPath = path.join(staticPath, fallBackToIndex ? index : request.url); + try { + const content = await fs.readFile(requestPath, 'utf8'); + response.setHeader('cache-control', fallBackToIndex ? noCache : maxAgeSevenDays); + response.setHeader('content-type', getMimeType(request.url)); + response.writeHead(200); + response.end(content); + } catch (error: unknown) { + consoleLog.error(chalk.red(error)); + response.setHeader('content-type', getMimeType(request.url)); + response.writeHead(existsSync(request.url) ? 500 : 404); + response.end(); + } + } + }; + +/** + * Intercept the response from Logto endpoint and replace Logto endpoint URLs in the response with the + * proxy URL. The string replace happens in the following cases: + * - The response is a redirect response, and the `location` property in response header may contain Logto + * endpoint URI. + * - The response body is JSON, which consists of properties such as `**_endpoint` and `redirectTo`. These + * properties may contain Logto endpoint URI. + * - The response is HTML content that contains a form. The form action URL may contain Logto endpoint URI. + * + * Note: the `issuer` and `jwks_uri` properties in the `/oidc/.well-known` response should not be replaced, + * even they also contain the Logto endpoint URI. + */ +export const createLogtoResponseHandler = async ({ + proxyResponse, + request, + response, + logtoEndpointUrl, + proxyUrl, + verbose, +}: ProxyResponseHandler) => { + const { location } = proxyResponse.headers; + if (location) { + // eslint-disable-next-line @silverhand/fp/no-mutation + proxyResponse.headers.location = location.replace(logtoEndpointUrl.href, proxyUrl.href); + } + + void responseInterceptor(async (responseBuffer, proxyResponse) => { + const responseBody = responseBuffer.toString(); + if (verbose) { + consoleLog.info(`Response received: ${chalk.green(responseBody)}`); + } + + if (proxyResponse.headers['content-type']?.includes('text/html')) { + return responseBody.replace(`action="${logtoEndpointUrl.href}`, `action="${proxyUrl.href}`); + } + + if (proxyResponse.headers['content-type']?.includes('application/json')) { + const jsonData = trySafe(() => JSON.parse(responseBody)); + + if (jsonData && typeof jsonData === 'object') { + const updatedEntries: Array<[string, unknown]> = Object.entries(jsonData).map( + ([key, value]) => { + if ((key === 'redirectTo' || key.endsWith('_endpoint')) && typeof value === 'string') { + return [key, value.replace(logtoEndpointUrl.href, proxyUrl.href)]; + } + return [key, value]; + } + ); + + return JSON.stringify(Object.fromEntries(updatedEntries)); + } + } + return responseBody; + })(proxyResponse, request, response); +}; + +export const checkExperienceInput = (url?: string, staticPath?: string) => { + if (staticPath && url) { + consoleLog.fatal('Only one of the experience URI or path can be provided.'); + } + if (!staticPath && !url) { + consoleLog.fatal('Either a sign-in experience URI or local path must be provided.'); + } + if (url && !isValidUrl(url)) { + consoleLog.fatal( + 'A valid sign-in experience URI must be provided. E.g.: http://localhost:4000' + ); + } + if (staticPath && !existsSync(path.join(staticPath, index))) { + consoleLog.fatal('The provided path does not contain a valid index.html file.'); + } +}; + +/** + * Check if the request path is a Logto request path. + * @example isLogtoRequestPath('/oidc/.well-known/openid-configuration') // true + * @example isLogtoRequestPath('/oidc/auth') // true + * @example isLogtoRequestPath('/api/interaction/submit') // true + * @example isLogtoRequestPath('/consent') // true + */ +export const isLogtoRequestPath = (requestPath?: string) => + ['/oidc/', '/api/'].some((path) => requestPath?.startsWith(path)) || requestPath === '/consent'; + +const isFileAssetPath = (url: string) => url.split('/').at(-1)?.includes('.'); + +const getMimeType = (requestPath: string) => { + const fallBackToIndex = !isFileAssetPath(requestPath); + if (fallBackToIndex) { + return indexContentType; + } + return mime.getType(requestPath) ?? 'application/octet-stream'; +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 13de2f797..7afd7c9ec 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { hideBin } from 'yargs/helpers'; import connector from './commands/connector/index.js'; import database from './commands/database/index.js'; import install from './commands/install/index.js'; +import proxy from './commands/proxy/index.js'; import translate from './commands/translate/index.js'; import { packageJson } from './package-json.js'; import { cliConfig, ConfigKey, consoleLog } from './utils.js'; @@ -48,6 +49,7 @@ void yargs(hideBin(process.argv)) .command(database) .command(connector) .command(translate) + .command(proxy) .demandCommand(1) .showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`) .strict() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 446bd4f98..e6a0a535b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,9 +130,15 @@ importers: hpagent: specifier: ^1.2.0 version: 1.2.0 + http-proxy-middleware: + specifier: ^3.0.0 + version: 3.0.0 inquirer: specifier: ^9.0.0 version: 9.1.4 + mime: + specifier: ^4.0.4 + version: 4.0.4 nanoid: specifier: ^5.0.1 version: 5.0.1 @@ -5942,6 +5948,9 @@ packages: '@types/http-errors@1.8.2': resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==} + '@types/http-proxy@1.17.14': + resolution: {integrity: sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==} + '@types/inquirer@9.0.3': resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==} @@ -8574,6 +8583,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-middleware@3.0.0: + resolution: {integrity: sha512-36AV1fIaI2cWRzHo+rbcxhe3M3jUDCNzc4D5zRl57sEWRAxdXYtw7FSQKYY6PDKssiAKjLYypbssHk+xs/kMXw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + http-proxy@1.18.1: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -8910,6 +8923,10 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -10049,6 +10066,11 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mime@4.0.4: + resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -15815,6 +15837,10 @@ snapshots: '@types/http-errors@1.8.2': {} + '@types/http-proxy@1.17.14': + dependencies: + '@types/node': 20.12.7 + '@types/inquirer@9.0.3': dependencies: '@types/through': 0.0.30 @@ -16660,7 +16686,7 @@ snapshots: axios@1.6.7: dependencies: - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.3.5) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -16668,7 +16694,7 @@ snapshots: axios@1.7.2: dependencies: - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.3.5) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -18520,7 +18546,9 @@ snapshots: flatted@3.3.1: {} - follow-redirects@1.15.6: {} + follow-redirects@1.15.6(debug@4.3.5): + optionalDependencies: + debug: 4.3.5 for-each@0.3.3: dependencies: @@ -19031,10 +19059,21 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy@1.18.1: + http-proxy-middleware@3.0.0: + dependencies: + '@types/http-proxy': 1.17.14 + debug: 4.3.5 + http-proxy: 1.18.1(debug@4.3.5) + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.5 + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1(debug@4.3.5): dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.3.5) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -19340,6 +19379,8 @@ snapshots: is-plain-obj@1.1.0: {} + is-plain-obj@3.0.0: {} + is-plain-obj@4.1.0: {} is-plain-object@5.0.0: {} @@ -20213,7 +20254,7 @@ snapshots: koa-proxies@0.12.4(koa@2.15.3): dependencies: - http-proxy: 1.18.1 + http-proxy: 1.18.1(debug@4.3.5) koa: 2.15.3 path-match: 1.2.4 uuid: 8.3.2 @@ -21243,6 +21284,8 @@ snapshots: mime@3.0.0: {} + mime@4.0.4: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -21923,7 +21966,7 @@ snapshots: yaml: 2.4.5 optionalDependencies: postcss: 8.4.39 - ts-node: 10.9.2(@swc/core@1.3.52)(@types/node@20.12.7)(typescript@5.5.3) + ts-node: 10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3) postcss-media-query-parser@0.2.3: {} @@ -23568,27 +23611,6 @@ snapshots: '@swc/core': 1.3.52(@swc/helpers@0.5.1) optional: true - ts-node@10.9.2(@swc/core@1.3.52)(@types/node@20.12.7)(typescript@5.5.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.12.7 - acorn: 8.11.3 - acorn-walk: 8.3.2 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.5.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.3.52(@swc/helpers@0.5.1) - optional: true - tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29