mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat: init logto tunnel
This commit is contained in:
parent
216859a906
commit
d226adb97f
12 changed files with 4591 additions and 143 deletions
3
packages/tunnel/README.md
Normal file
3
packages/tunnel/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Logto tunnel
|
||||||
|
|
||||||
|
🚧 Working in progress
|
48
packages/tunnel/package.json
Normal file
48
packages/tunnel/package.json
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "logto-tunnel",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Logto tunnel services for custom sign-in experience local development.",
|
||||||
|
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||||
|
"homepage": "https://github.com/logto-io/logto#readme",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"private": true,
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"precommit": "lint-staged",
|
||||||
|
"build": "rm -rf lib && tsc -p tsconfig.build.json",
|
||||||
|
"start": "node .",
|
||||||
|
"start:dev": "DEBUG=http-proxy-middleware* pnpm build && node .",
|
||||||
|
"lint": "eslint --ext .ts src",
|
||||||
|
"prepack": "pnpm build"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/node": "^2.5.2",
|
||||||
|
"@silverhand/essentials": "^2.9.1",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"http-proxy-middleware": "^3.0.0",
|
||||||
|
"ky": "^1.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@silverhand/eslint-config": "6.0.1",
|
||||||
|
"@silverhand/ts-config": "6.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.9.5",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"lint-staged": "^15.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "@silverhand"
|
||||||
|
},
|
||||||
|
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||||
|
}
|
3893
packages/tunnel/pnpm-lock.yaml
Normal file
3893
packages/tunnel/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
93
packages/tunnel/src/client/index.ts
Normal file
93
packages/tunnel/src/client/index.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import type { LogtoConfig, SignInOptions } from '@logto/node';
|
||||||
|
import LogtoClient from '@logto/node';
|
||||||
|
import { assert } from '@silverhand/essentials';
|
||||||
|
import ky from 'ky';
|
||||||
|
|
||||||
|
import { appId, logtoExperienceUrl, redirectUri, tenantId } from '../consts.js';
|
||||||
|
import { exitOnFatalError } from '../utils.js';
|
||||||
|
|
||||||
|
import { MemoryStorage } from './storage.js';
|
||||||
|
|
||||||
|
export const defaultConfig = {
|
||||||
|
endpoint: logtoExperienceUrl,
|
||||||
|
appId,
|
||||||
|
persistAccessToken: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Client {
|
||||||
|
public rawCookies: string[] = [];
|
||||||
|
protected readonly config: LogtoConfig;
|
||||||
|
protected readonly storage: MemoryStorage;
|
||||||
|
protected readonly logto: LogtoClient;
|
||||||
|
|
||||||
|
private navigateUrl?: string;
|
||||||
|
private signInCallbackUrl?: string;
|
||||||
|
|
||||||
|
constructor(config?: Partial<LogtoConfig>) {
|
||||||
|
this.storage = new MemoryStorage();
|
||||||
|
this.config = { ...defaultConfig, ...config };
|
||||||
|
|
||||||
|
this.logto = new LogtoClient(this.config, {
|
||||||
|
navigate: (url: string) => {
|
||||||
|
this.navigateUrl = url;
|
||||||
|
},
|
||||||
|
storage: this.storage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get sessionCookie(): string {
|
||||||
|
return this.rawCookies.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initSession(options: Omit<SignInOptions, 'redirectUri'> = {}) {
|
||||||
|
try {
|
||||||
|
assert(tenantId, new Error('LOGTO_TENANT_ID is not set.'));
|
||||||
|
await this.logto.signIn({ redirectUri, ...options });
|
||||||
|
|
||||||
|
assert(this.navigateUrl, new Error('Unable to navigate to sign-in uri'));
|
||||||
|
assert(
|
||||||
|
this.navigateUrl.startsWith(`${this.config.endpoint}/oidc/auth`),
|
||||||
|
new Error('Unable to navigate to sign in uri')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock SDK sign-in navigation
|
||||||
|
const response = await ky(this.navigateUrl, {
|
||||||
|
redirect: 'manual',
|
||||||
|
throwHttpErrors: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: should redirect to sign-in page
|
||||||
|
assert(
|
||||||
|
response.status === 303 && response.headers.get('location')?.startsWith('/sign-in'),
|
||||||
|
new Error('Visit sign-in uri failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get session cookies
|
||||||
|
this.rawCookies = response.headers.getSetCookie();
|
||||||
|
assert(this.sessionCookie, new Error('Get cookies from authorization endpoint failed'));
|
||||||
|
} catch (error) {
|
||||||
|
exitOnFatalError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleSignInCallback() {
|
||||||
|
try {
|
||||||
|
assert(this.signInCallbackUrl, new Error('No sign-in callback url'));
|
||||||
|
await this.logto.handleSignInCallback(this.signInCallbackUrl);
|
||||||
|
} catch (error) {
|
||||||
|
exitOnFatalError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdToken() {
|
||||||
|
return this.logto.getIdToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getIdTokenClaims() {
|
||||||
|
return this.logto.getIdTokenClaims();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSignInCallbackUrl(url: string) {
|
||||||
|
this.signInCallbackUrl = url;
|
||||||
|
}
|
||||||
|
}
|
23
packages/tunnel/src/client/storage.ts
Normal file
23
packages/tunnel/src/client/storage.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { PersistKey, Storage } from '@logto/node';
|
||||||
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
export class MemoryStorage implements Storage<PersistKey> {
|
||||||
|
private storage: { [key in PersistKey]: Nullable<string> } = {
|
||||||
|
idToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
accessToken: null,
|
||||||
|
signInSession: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async getItem(key: PersistKey): Promise<Nullable<string>> {
|
||||||
|
return this.storage[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(key: PersistKey, value: string): Promise<void> {
|
||||||
|
this.storage[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: PersistKey): Promise<void> {
|
||||||
|
this.storage[key] = null;
|
||||||
|
}
|
||||||
|
}
|
14
packages/tunnel/src/consts.ts
Normal file
14
packages/tunnel/src/consts.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { appendPath } from '@silverhand/essentials';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const tunnelPort = process.env.TUNNEL_PORT ?? 9000;
|
||||||
|
export const tenantId = process.env.LOGTO_TENANT_ID;
|
||||||
|
|
||||||
|
export const logtoExperienceUrl = `https://${tenantId}.app.logto.dev`;
|
||||||
|
export const logtoTunnelServiceUrl = `http://localhost:${tunnelPort}`;
|
||||||
|
export const customUiSignInUrl = process.env.CUSTOM_UI_SIGN_IN_URL ?? 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
|
export const appId = 'demo-app';
|
||||||
|
export const redirectUri = appendPath(new URL(logtoExperienceUrl), appId).href;
|
119
packages/tunnel/src/index.ts
Normal file
119
packages/tunnel/src/index.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import express from 'express';
|
||||||
|
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||||
|
|
||||||
|
import Client from './client/index.js';
|
||||||
|
import {
|
||||||
|
customUiSignInUrl,
|
||||||
|
logtoExperienceUrl,
|
||||||
|
logtoTunnelServiceUrl,
|
||||||
|
tunnelPort,
|
||||||
|
} from './consts.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
const client = new Client();
|
||||||
|
await client.initSession();
|
||||||
|
|
||||||
|
// Modify the response body, replace the "redirectTo" url hostname with localhost
|
||||||
|
const modifyResponseBody = responseInterceptor(async (responseBuffer, proxyResponse) => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-let
|
||||||
|
let responseBody = responseBuffer.toString(); // Convert buffer to string
|
||||||
|
|
||||||
|
// Check if the response is JSON
|
||||||
|
if (proxyResponse.headers['content-type']?.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const jsonResponse = JSON.parse(responseBody);
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-call
|
||||||
|
jsonResponse.redirectTo &&= jsonResponse.redirectTo.replace(
|
||||||
|
logtoExperienceUrl,
|
||||||
|
logtoTunnelServiceUrl
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
responseBody = JSON.stringify(jsonResponse); // Convert back to JSON string
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red('Error parsing JSON response:\n'), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cookies
|
||||||
|
const setCookie = proxyResponse.headers['set-cookie'];
|
||||||
|
if (setCookie?.length) {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
client.rawCookies = setCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup a route to handle the sign-in callback and print the ID token
|
||||||
|
app.get('/result', async (_, response) => {
|
||||||
|
await client.handleSignInCallback();
|
||||||
|
const token = await client.getIdToken();
|
||||||
|
response.send('ID token:\n' + token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proxy path for the local custom sign-in web app
|
||||||
|
app.use(
|
||||||
|
'/custom-experience/',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: customUiSignInUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Proxy all file resources (*.js, *.css, etc.) to the local SPA web app
|
||||||
|
app.use(
|
||||||
|
'/',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: customUiSignInUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathFilter: '**/*.*',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Proxy all other API requests and page navigation requests to the Logto server
|
||||||
|
app.use(
|
||||||
|
'/',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: logtoExperienceUrl,
|
||||||
|
pathFilter: (path) => {
|
||||||
|
return path !== '/result';
|
||||||
|
},
|
||||||
|
changeOrigin: true,
|
||||||
|
selfHandleResponse: true,
|
||||||
|
on: {
|
||||||
|
proxyReq: (proxyRequest) => {
|
||||||
|
proxyRequest.setHeader('cookie', client.sessionCookie);
|
||||||
|
},
|
||||||
|
proxyRes: async (proxyResponse, request, response) => {
|
||||||
|
await modifyResponseBody(proxyResponse, request, response);
|
||||||
|
const { location } = proxyResponse.headers;
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
if (location.startsWith(`${logtoExperienceUrl}/demo-app?code=`)) {
|
||||||
|
client.setSignInCallbackUrl(location);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
proxyResponse.headers.location = '/result';
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
proxyResponse.headers.location = location.replace(
|
||||||
|
logtoExperienceUrl,
|
||||||
|
logtoTunnelServiceUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.listen(tunnelPort, () => {
|
||||||
|
console.log(chalk.green('✅ Logto tunnel service is running...'));
|
||||||
|
console.log(
|
||||||
|
'Your custom sign-in page is available at:',
|
||||||
|
chalk.blue(`http://localhost:${tunnelPort}/custom-experience`)
|
||||||
|
);
|
||||||
|
});
|
3
packages/tunnel/src/types.ts
Normal file
3
packages/tunnel/src/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export type RedirectResponse = {
|
||||||
|
redirectTo: string;
|
||||||
|
};
|
8
packages/tunnel/src/utils.ts
Normal file
8
packages/tunnel/src/utils.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
export const exitOnFatalError = (error: unknown) => {
|
||||||
|
console.error(chalk.red(error));
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
|
process.exit(1);
|
||||||
|
};
|
4
packages/tunnel/tsconfig.build.json
Normal file
4
packages/tunnel/tsconfig.build.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig",
|
||||||
|
"include": ["src"],
|
||||||
|
}
|
11
packages/tunnel/tsconfig.json
Normal file
11
packages/tunnel/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"exclude": ["**/alteration-scripts"]
|
||||||
|
}
|
515
pnpm-lock.yaml
515
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue