0
Fork 0
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:
Charles Zhao 2024-07-19 13:20:18 +08:00
parent 216859a906
commit d226adb97f
No known key found for this signature in database
GPG key ID: 4858774754C92DF2
12 changed files with 4591 additions and 143 deletions

View file

@ -0,0 +1,3 @@
# Logto tunnel
🚧 Working in progress

View 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"
}

File diff suppressed because it is too large Load diff

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

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

View 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;

View 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`)
);
});

View file

@ -0,0 +1,3 @@
export type RedirectResponse = {
redirectTo: string;
};

View 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);
};

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig",
"include": ["src"],
}

View file

@ -0,0 +1,11 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"types": ["node"]
},
"include": [
"src"
],
"exclude": ["**/alteration-scripts"]
}

File diff suppressed because it is too large Load diff