0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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
This commit is contained in:
Charles Zhao 2024-08-01 12:47:24 +08:00 committed by GitHub
parent 3f014eb573
commit 2d0502a427
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 369 additions and 28 deletions

View file

@ -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://<tenant-id>.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://<tenant-id>.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!

View file

@ -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",

View file

@ -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<unknown, ProxyCommandArgs> = {
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://<tenant-id>.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;

View file

@ -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;
};

View file

@ -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<unknown>(() => 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';
};

View file

@ -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()

View file

@ -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