-
-
Notifications
You must be signed in to change notification settings - Fork 487
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
3f014eb
commit 2d0502a
Showing
7 changed files
with
369 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.