Skip to content

Commit

Permalink
feat(cli): add cli command to setup custom ui local debugging proxy (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
charIeszhao authored Aug 1, 2024
1 parent 3f014eb commit 2d0502a
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 28 deletions.
34 changes: 34 additions & 0 deletions .changeset/slow-buses-rhyme.md
Original file line number Diff line number Diff line change
@@ -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!
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/commands/proxy/index.ts
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions packages/cli/src/commands/proxy/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
157 changes: 157 additions & 0 deletions packages/cli/src/commands/proxy/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
};
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 2d0502a

Please sign in to comment.