Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add cli command to setup custom ui local debugging proxy #6365

Merged
merged 8 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
gao-sun marked this conversation as resolved.
Show resolved Hide resolved

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

charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
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));
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
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';
gao-sun marked this conversation as resolved.
Show resolved Hide resolved

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
Loading