0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

Merge pull request #3030 from logto-io/gao-separate-console-server

refactor(core)!: separate admin console server
This commit is contained in:
Gao Sun 2023-02-07 15:35:47 +08:00 committed by GitHub
commit f3fb0ec2a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 172 additions and 226 deletions

View file

@ -101,5 +101,3 @@ jobs:
cd tests/packages/integration-tests
pnpm build
pnpm test:${{ matrix.test_target }}
env:
INTEGRATION_TESTS_LOGTO_URL: http://localhost:3001

View file

@ -1,18 +1,18 @@
import fs from 'fs/promises';
import http2 from 'http2';
import { deduplicate } from '@silverhand/essentials';
import { toTitle } from '@silverhand/essentials';
import chalk from 'chalk';
import type Koa from 'koa';
import { EnvSet } from '#src/env-set/index.js';
import { defaultTenant, tenantPool } from '#src/tenants/index.js';
const logListening = () => {
const { localhostUrl, endpoint } = EnvSet.values;
const logListening = (type: 'core' | 'admin' = 'core') => {
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
for (const url of deduplicate([localhostUrl, endpoint.toString()])) {
console.log(chalk.bold(chalk.green(`App is running at ${url}`)));
for (const url of urlSet.deduplicated()) {
console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url}`)));
}
};
@ -43,23 +43,40 @@ export default async function initApp(app: Koa): Promise<void> {
return tenant.run(ctx, next);
});
const { isHttpsEnabled, httpsCert, httpsKey, port } = EnvSet.values;
const { isHttpsEnabled, httpsCert, httpsKey, urlSet, adminUrlSet } = EnvSet.values;
if (isHttpsEnabled && httpsCert && httpsKey) {
http2
.createSecureServer(
const createHttp2Server = async () =>
http2.createSecureServer(
{ cert: await fs.readFile(httpsCert), key: await fs.readFile(httpsKey) },
app.callback()
)
.listen(port, () => {
logListening();
);
const coreServer = await createHttp2Server();
coreServer.listen(urlSet.port, () => {
logListening();
});
// Create another server if admin localhost enabled
if (!adminUrlSet.isLocalhostDisabled) {
const adminServer = await createHttp2Server();
adminServer.listen(adminUrlSet.port, () => {
logListening('admin');
});
}
return;
}
// Chrome doesn't allow insecure http/2 servers
app.listen(port, () => {
// Chrome doesn't allow insecure HTTP/2 servers, stick with HTTP for localhost.
app.listen(urlSet.port, () => {
logListening();
});
// Create another server if admin localhost enabled
if (!adminUrlSet.isLocalhostDisabled) {
app.listen(adminUrlSet.port, () => {
logListening('admin');
});
}
}

View file

@ -0,0 +1,52 @@
import net from 'net';
import { tryThat } from '@logto/shared';
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import UrlSet from './UrlSet.js';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage } from './throw-errors.js';
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
type MultiTenancyMode = 'domain' | 'env';
export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === 'test';
public readonly isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
public readonly httpsCert = process.env.HTTPS_CERT_PATH;
public readonly httpsKey = process.env.HTTPS_KEY_PATH;
public readonly isHttpsEnabled = Boolean(this.httpsCert && this.httpsKey);
public readonly isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001);
public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_');
// eslint-disable-next-line unicorn/consistent-function-scoping
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
public readonly developmentTenantId = getEnv(developmentTenantIdKey);
public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES');
public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID');
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
public readonly ignoreConnectorVersionCheck = isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK'));
public get dbUrl(): string {
return this.databaseUrl;
}
public get endpoint(): string {
return this.urlSet.endpoint;
}
public get multiTenancyMode(): MultiTenancyMode {
const { hostname } = new URL(this.endpoint);
return this.isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost'
? 'domain'
: 'env';
}
}

View file

@ -0,0 +1,48 @@
import { deduplicate, getEnv, trySafe } from '@silverhand/essentials';
import { isTrue } from './parameters.js';
const localhostDisabledMessage = 'Localhost has been disabled in this URL Set.';
export default class UrlSet {
readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT');
public readonly isLocalhostDisabled = isTrue(getEnv(this.envPrefix + 'DISABLE_LOCALHOST'));
constructor(
public readonly isHttpsEnabled: boolean,
protected readonly defaultPort: number,
protected readonly envPrefix: string = ''
) {}
public deduplicated(): string[] {
return deduplicate(
[trySafe(() => this.localhostUrl), trySafe(() => this.endpoint)].filter(
(value): value is string => typeof value === 'string'
)
);
}
public get port() {
if (this.isLocalhostDisabled) {
throw new Error(localhostDisabledMessage);
}
return this.#port;
}
public get localhostUrl() {
return `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`;
}
public get endpoint() {
const value = this.#endpoint || this.localhostUrl;
if (this.isLocalhostDisabled && new URL(value).hostname === 'localhost') {
throw new Error(localhostDisabledMessage);
}
return value;
}
}

View file

@ -1,8 +1,4 @@
import net from 'net';
import { tryThat } from '@logto/shared';
import type { Optional } from '@silverhand/essentials';
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import type { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik';
@ -10,12 +6,12 @@ import type { DatabasePool } from 'slonik';
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { appendPath } from '#src/utils/url.js';
import GlobalValues from './GlobalValues.js';
import { checkAlterationState } from './check-alteration-state.js';
import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage, throwNotLoadedError } from './throw-errors.js';
import { throwNotLoadedError } from './throw-errors.js';
export enum MountedApps {
Api = 'api',
@ -25,72 +21,8 @@ export enum MountedApps {
Welcome = 'welcome',
}
type MultiTenancyMode = 'domain' | 'env';
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
const loadEnvValues = () => {
const isProduction = getEnv('NODE_ENV') === 'production';
const isTest = getEnv('NODE_ENV') === 'test';
const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
const isHttpsEnabled = Boolean(process.env.HTTPS_CERT_PATH && process.env.HTTPS_KEY_PATH);
const isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
const port = Number(getEnv('PORT', '3001'));
const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`;
const endpoint = getEnv('ENDPOINT', localhostUrl);
const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
const { hostname } = new URL(endpoint);
const multiTenancyMode: MultiTenancyMode =
isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost' ? 'domain' : 'env';
const developmentTenantId = getEnv(developmentTenantIdKey);
if (!isMultiTenancy && developmentTenantId) {
throw new Error(
`Multi-tenancy is disabled but development tenant env \`${developmentTenantIdKey}\` found. Please enable multi-tenancy by setting \`${enableMultiTenancyKey}\` to true.`
);
}
if (isMultiTenancy && multiTenancyMode === 'env') {
if (isProduction) {
throw new Error(
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
'An endpoint with a valid domain is required for multi-tenancy mode.'
);
}
console.warn(
'[warn]',
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
`Logto is using \`${developmentTenantIdKey}\` env (current value: ${developmentTenantId}) for tenant recognition which is not supported in production.`
);
}
return Object.freeze({
isTest,
isIntegrationTest,
isProduction,
isHttpsEnabled,
isMultiTenancy,
httpsCert: process.env.HTTPS_CERT_PATH,
httpsKey: process.env.HTTPS_KEY_PATH,
port,
localhostUrl,
endpoint,
multiTenancyMode,
dbUrl: databaseUrl,
userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'),
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
developmentTenantId,
trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')),
adminConsoleUrl: appendPath(endpoint, '/console'),
ignoreConnectorVersionCheck: isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK')),
});
};
export class EnvSet {
static values: ReturnType<typeof loadEnvValues> = loadEnvValues();
static values = new GlobalValues();
static default = new EnvSet(EnvSet.values.dbUrl);
static get isTest() {

View file

@ -5,8 +5,7 @@ import Koa from 'koa';
dotenv.config({ path: await findUp('.env', {}) });
// Import after env has configured
// Import after env has been configured
const { loadConnectorFactories } = await import('./utils/connectors/factories.js');
const { EnvSet } = await import('./env-set/index.js');
const { default: initI18n } = await import('./i18n/init.js');

View file

@ -1,25 +1,30 @@
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import { appendPath } from '#src/utils/url.js';
export default function koaWelcomeProxy<
export default function koaConsoleRedirectProxy<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { hasActiveUsers } = queries.users;
const { adminConsoleUrl } = EnvSet.values;
return async (ctx) => {
if (await hasActiveUsers()) {
ctx.redirect(adminConsoleUrl.toString());
return async (ctx, next) => {
const hasUser = await hasActiveUsers();
if ((ctx.path === '/' || ctx.path === '/console') && !hasUser) {
ctx.redirect('/console/welcome');
return;
}
ctx.redirect(appendPath(adminConsoleUrl, '/welcome').toString());
if (ctx.path === '/console/welcome' && hasUser) {
ctx.redirect('/console');
return;
}
return next();
};
}

View file

@ -1,31 +0,0 @@
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaRootProxy from './koa-root-proxy.js';
const { jest } = import.meta;
describe('koaRootProxy', () => {
const next = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('empty path should directly return', async () => {
const ctx = createContextWithRouteParameters({
url: '/',
});
await koaRootProxy()(ctx, next);
expect(next).not.toBeCalled();
});
it('non-empty path should return next', async () => {
const ctx = createContextWithRouteParameters({
url: '/console',
});
await koaRootProxy()(ctx, next);
expect(next).toBeCalled();
});
});

View file

@ -1,26 +0,0 @@
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import { appendPath } from '#src/utils/url.js';
export default function koaRootProxy<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { endpoint } = EnvSet.values;
return async (ctx, next) => {
const requestPath = ctx.request.path;
// Redirect root path to the Admin Console welcome page
if (requestPath === '/') {
ctx.redirect(appendPath(endpoint, '/welcome').toString());
return;
}
return next();
};
}

View file

@ -1,46 +0,0 @@
import { pickDefault } from '@logto/shared/esm';
import { EnvSet, MountedApps } from '#src/env-set/index.js';
import { MockQueries } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const hasActiveUsers = jest.fn();
const queries = new MockQueries({ users: { hasActiveUsers } });
const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js'));
describe('koaWelcomeProxy', () => {
const next = jest.fn();
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
it('should redirect to admin console if has AdminUsers', async () => {
const { endpoint } = EnvSet.values;
hasActiveUsers.mockResolvedValue(true);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});
await koaWelcomeProxy(queries)(ctx, next);
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}`);
expect(next).not.toBeCalled();
});
it('should redirect to welcome page if has no Users', async () => {
const { endpoint } = EnvSet.values;
hasActiveUsers.mockResolvedValue(false);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});
await koaWelcomeProxy(queries)(ctx, next);
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}/welcome`);
expect(next).not.toBeCalled();
});
});

View file

@ -14,11 +14,8 @@ import { appendPath } from '#src/utils/url.js';
import { getConstantClientMetadata } from './utils.js';
const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
const { localhostUrl, adminConsoleUrl } = EnvSet.values;
const urls = deduplicate([
appendPath(localhostUrl, '/console').toString(),
adminConsoleUrl.toString(),
]);
const { adminUrlSet } = EnvSet.values;
const urls = adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString());
return {
...getConstantClientMetadata(envSet, ApplicationType.SPA),
@ -32,11 +29,8 @@ const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
const buildDemoAppUris = (
oidcClientMetadata: OidcClientMetadata
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
const { localhostUrl, endpoint } = EnvSet.values;
const urls = [
appendPath(localhostUrl, MountedApps.DemoApp).toString(),
appendPath(endpoint, MountedApps.DemoApp).toString(),
];
const { urlSet } = EnvSet.values;
const urls = urlSet.deduplicated().map((url) => appendPath(url, MountedApps.DemoApp).toString());
const data = {
redirectUris: deduplicate([...urls, ...oidcClientMetadata.redirectUris]),

View file

@ -8,14 +8,13 @@ import type Provider from 'oidc-provider';
import { EnvSet, MountedApps } from '#src/env-set/index.js';
import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
import koaI18next from '#src/middleware/koa-i18next.js';
import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
import koaRootProxy from '#src/middleware/koa-root-proxy.js';
import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import { createModelRouters } from '#src/model-routers/index.js';
import initOidc from '#src/oidc/init.js';
@ -80,17 +79,17 @@ export default class Tenant implements TenantContext {
app.use(koaConnectorErrorHandler());
app.use(koaI18next());
// Mount APIs
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
app.use(mount('/api', apisApp));
app.use(mount('/', koaRootProxy()));
app.use(mount('/' + MountedApps.Welcome, koaWelcomeProxy(queries)));
// Mount Admin Console
app.use(koaConsoleRedirectProxy(queries));
app.use(
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
);
// Mount demo app
app.use(
mount(
'/' + MountedApps.DemoApp,
@ -101,6 +100,7 @@ export default class Tenant implements TenantContext {
)
);
// Mount UI
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
this.app = app;

View file

@ -1,12 +1,16 @@
import { SignInIdentifier, demoAppApplicationId } from '@logto/schemas';
import { assertEnv } from '@silverhand/essentials';
import { getEnv } from '@silverhand/essentials';
export const logtoUrl = assertEnv('INTEGRATION_TESTS_LOGTO_URL');
export const logtoUrl = getEnv('INTEGRATION_TESTS_LOGTO_URL', 'http://localhost:3001');
export const logtoConsoleUrl = getEnv(
'INTEGRATION_TESTS_LOGTO_CONSOLE_URL',
'http://localhost:3002'
);
export const discoveryUrl = `${logtoUrl}/oidc/.well-known/openid-configuration`;
export const demoAppRedirectUri = `${logtoUrl}/${demoAppApplicationId}`;
export const adminConsoleRedirectUri = `${logtoUrl}/console/callback`;
export const adminConsoleRedirectUri = `${logtoConsoleUrl}/console/callback`;
export const signUpIdentifiers = {
username: [SignInIdentifier.Username],

View file

@ -1,4 +1,4 @@
import { logtoUrl } from '#src/constants.js';
import { logtoConsoleUrl } from '#src/constants.js';
import { generatePassword } from '#src/utils.js';
describe('smoke testing', () => {
@ -7,11 +7,11 @@ describe('smoke testing', () => {
it('opens with app element and navigates to welcome page', async () => {
const navigation = page.waitForNavigation({ waitUntil: 'networkidle0' });
await page.goto(logtoUrl);
await page.goto(logtoConsoleUrl);
await navigation;
await expect(page.waitForSelector('#app')).resolves.not.toBeNull();
expect(page.url()).toBe(new URL('console/welcome', logtoUrl).href);
expect(page.url()).toBe(new URL('console/welcome', logtoConsoleUrl).href);
});
it('registers a new admin account and automatically signs in', async () => {
@ -22,7 +22,7 @@ describe('smoke testing', () => {
await createAccountButton.click();
await navigateToRegister;
expect(page.url()).toBe(new URL('register', logtoUrl).href);
expect(page.url()).toBe(new URL('register', logtoConsoleUrl).href);
const usernameField = await page.waitForSelector('input[name=new-username]');
const submitButton = await page.waitForSelector('button');
@ -33,7 +33,7 @@ describe('smoke testing', () => {
await submitButton.click();
await navigateToSignIn;
expect(page.url()).toBe(new URL('register/username/password', logtoUrl).href);
expect(page.url()).toBe(new URL('register/username/password', logtoConsoleUrl).href);
const passwordField = await page.waitForSelector('input[name=newPassword]');
const confirmPasswordField = await page.waitForSelector('input[name=confirmPassword]');
@ -45,7 +45,7 @@ describe('smoke testing', () => {
await saveButton.click();
await navigateToGetStarted;
expect(page.url()).toBe(new URL('console/get-started', logtoUrl).href);
expect(page.url()).toBe(new URL('console/get-started', logtoConsoleUrl).href);
});
it('signs out of admin console', async () => {
@ -62,7 +62,7 @@ describe('smoke testing', () => {
await signOutButton.click();
await navigation;
expect(page.url()).toBe(new URL('sign-in', logtoUrl).href);
expect(page.url()).toBe(new URL('sign-in', logtoConsoleUrl).href);
});
it('signs in to admin console', async () => {
@ -77,7 +77,7 @@ describe('smoke testing', () => {
await submitButton.click();
await navigation;
expect(page.url()).toBe(new URL('console/get-started', logtoUrl).href);
expect(page.url()).toBe(new URL('console/get-started', logtoConsoleUrl).href);
const userElement = await page.waitForSelector('div[class$=topbar] > div:last-child');
const usernameString = await userElement.$eval('div > div', (element) => element.textContent);