mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core): use SSOT for env variables (#578)
* refactor(core): use SSOT for env variables * fix(core): tests
This commit is contained in:
parent
3dc07312a0
commit
08ce66f317
16 changed files with 142 additions and 69 deletions
|
@ -3,6 +3,7 @@ module.exports = {
|
|||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['./jest.setup.js', 'jest-matcher-specific-error'],
|
||||
globalSetup: './jest.global-setup.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.test.json',
|
||||
|
|
29
packages/core/jest.global-setup.js
Normal file
29
packages/core/jest.global-setup.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
/* eslint-disable unicorn/prefer-module */
|
||||
/**
|
||||
* Generate private key for tests
|
||||
*/
|
||||
const { generateKeyPairSync } = require('crypto');
|
||||
const { writeFileSync } = require('fs');
|
||||
|
||||
const privateKeyPath = 'oidc-private-key.test.pem';
|
||||
|
||||
module.exports = () => {
|
||||
const { privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
|
||||
writeFileSync(privateKeyPath, privateKey);
|
||||
};
|
||||
|
||||
exports = module.exports;
|
||||
exports.privateKeyPath = privateKeyPath;
|
||||
|
||||
/* eslint-enable unicorn/prefer-module */
|
|
@ -1,12 +1,12 @@
|
|||
/* eslint-disable unicorn/prefer-module */
|
||||
/**
|
||||
* Setup environment variables for unit test
|
||||
*/
|
||||
const OIDC_PROVIDER_PRIVATE_KEY_BASE64 =
|
||||
'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDV2dJQkFBS0JnR3pLendQcVp6Q3dncjR5a0U1NTN2aWw3QTZYM2l1VnJ3TVJtbVJDTVNBL3lkUm04bXA1CjlHZUYyMlRCSVBtUEVNM29Lbnk4KytFL2FDRnByWXVDa0loREhodVR5N1diT25nd3kyb3JpYnNEQm1OS3FybTkKM0xkYWYrZm1aU2tsL0FMUjZNeUhNV2dTUkQrbFhxVnplNFdSRGIzVTlrTyt3RmVXUlNZNmlRL2pBZ01CQUFFQwpnWUJOZkczUjVpUTFJNk1iZ0x3VGlPM3N2NURRSEE3YmtETWt4bWJtdmRacmw4TlRDemZoNnBiUEhTSFVNMUlmCkxXelVtMldYanFzQUZiOCsvUnZrWDh3OHI3SENNUUdLVGs0ay9adkZ5YUhkM2tIUXhjSkJPakNOUUtjS2NZalUKRGdnTUVJeW5PblNZNjJpWEV6RExKVTJEMVUrY3JEbTZXUTVHaG1NS1p2Vnl3UUpCQU1lcFBFV2gwakNDOEdmQwpQQU1yT1JvOHJYeHYwVEdXNlJWYmxad0ppdjhNeGZacnpZT1cwZUFPek9IK0ZRWE90SjNTdUZONzdEcVQ5TDI3CmN2M3QySkVDUVFDTGZZeVl2ZUg0UnY2bnVET0RnckkzRUJHMFNJbURHcC94UUV2NEk5Z0hrRFF0aFF4bW5xNTEKZ1QxajhFN1lmRHEwMTkvN2htL3dmMXNzMERQNkpic3pBa0JqOEUzKy9MVGRHMjJDUWpNUDB2N09KemtmWkVqdAo3WC9WOVBXNkdQeStGWUt4aWR4ZzFZbFFBWmlFTms0SGppUFNLN3VmN2hPY2JwcStyYWt0ZVhSQkFrQmhaaFFECkh5c20wbVBFTnNGNWhZdnRHTUpUOFFaYnpmNTZWUnYyc3dpSUYyL25qT3hneDFJbjZFczJlamlEdnhLNjdiV1AKQ29zbEViaFhMVFh0NStTekFrQjJQOUYzNExubE9tVjh4Zjk1VmVlcXNPbDFmWWx2Uy9vUUx1a2ZxVkJsTmtzNgpzdmNLVDJOQjlzSHlCeE8vY3Zqa0ZpWXdHR2MzNjlmQklkcDU1S2IwCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t';
|
||||
const UI_SIGN_IN_ROUTE = '/sign-in';
|
||||
|
||||
const { privateKeyPath } = require('./jest.global-setup.js');
|
||||
|
||||
process.env = {
|
||||
...process.env,
|
||||
OIDC_PROVIDER_PRIVATE_KEY_BASE64,
|
||||
UI_SIGN_IN_ROUTE,
|
||||
OIDC_PRIVATE_KEY_PATH: privateKeyPath,
|
||||
};
|
||||
/* eslint-enable unicorn/prefer-module */
|
||||
|
|
|
@ -5,7 +5,7 @@ import Koa from 'koa';
|
|||
import koaLogger from 'koa-logger';
|
||||
import mount from 'koa-mount';
|
||||
|
||||
import { MountedApps, port } from '@/env/consts';
|
||||
import envSet, { MountedApps } from '@/env-set';
|
||||
import koaConnectorErrorHandler from '@/middleware/koa-connector-error-handle';
|
||||
import koaErrorHandler from '@/middleware/koa-error-handler';
|
||||
import koaI18next from '@/middleware/koa-i18next';
|
||||
|
@ -35,12 +35,12 @@ export default async function initApp(app: Koa): Promise<void> {
|
|||
);
|
||||
app.use(koaSpaProxy());
|
||||
|
||||
const { HTTPS_CERT, HTTPS_KEY } = process.env;
|
||||
const { httpsCert, httpsKey, port } = envSet.values;
|
||||
|
||||
if (HTTPS_CERT && HTTPS_KEY) {
|
||||
if (httpsCert && httpsKey) {
|
||||
https
|
||||
.createServer(
|
||||
{ cert: await fs.readFile(HTTPS_CERT), key: await fs.readFile(HTTPS_KEY) },
|
||||
{ cert: await fs.readFile(httpsCert), key: await fs.readFile(httpsKey) },
|
||||
app.callback()
|
||||
)
|
||||
.listen(port, () => {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { getEnv } from '@silverhand/essentials';
|
||||
import { createPool } from 'slonik';
|
||||
import { createInterceptors } from 'slonik-interceptor-preset';
|
||||
|
||||
import envSet from '@/env-set';
|
||||
|
||||
const interceptors = [...createInterceptors()];
|
||||
|
||||
const pool = createPool(getEnv('DB_URL'), { interceptors });
|
||||
const pool = createPool(envSet.values.dbUrl, { interceptors });
|
||||
|
||||
export default pool;
|
||||
|
|
75
packages/core/src/env-set/index.ts
Normal file
75
packages/core/src/env-set/index.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import crypto from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { assertEnv, getEnv, Optional } from '@silverhand/essentials';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { string, number } from 'zod';
|
||||
|
||||
export enum MountedApps {
|
||||
Api = 'api',
|
||||
Oidc = 'oidc',
|
||||
Console = 'console',
|
||||
}
|
||||
|
||||
const readPrivateKey = (path: string): Optional<string> => {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadOidcValues = (port: number) => {
|
||||
const privateKeyPath = getEnv('OIDC_PRIVATE_KEY_PATH', 'oidc-private-key.pem');
|
||||
const privateKey = crypto.createPrivateKey(readPrivateKey(privateKeyPath) ?? '');
|
||||
const publicKey = crypto.createPublicKey(privateKey);
|
||||
|
||||
return {
|
||||
privateKeyPath,
|
||||
privateKey,
|
||||
publicKey,
|
||||
issuer: getEnv('OIDC_ISSUER', `http://localhost:${port}/oidc`),
|
||||
adminResource: getEnv('ADMIN_RESOURCE', 'https://api.logto.io'),
|
||||
defaultIdTokenTtl: 60 * 60,
|
||||
defaultRefreshTokenTtl: 14 * 24 * 60 * 60,
|
||||
};
|
||||
};
|
||||
|
||||
const loadEnvValues = () => {
|
||||
const isProduction = getEnv('NODE_ENV') === 'production';
|
||||
const isTest = getEnv('NODE_ENV') === 'test';
|
||||
const port = Number(getEnv('PORT', '3001'));
|
||||
|
||||
return Object.freeze({
|
||||
isTest,
|
||||
isProduction,
|
||||
dbUrl: isTest ? getEnv('DB_URL') : assertEnv('DB_URL'),
|
||||
httpsCert: process.env.HTTPS_CERT,
|
||||
httpsKey: process.env.HTTPS_KEY,
|
||||
port,
|
||||
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
|
||||
trustingTlsOffloadingProxies: getEnv('TRUSTING_TLS_OFFLOADING_PROXIES') === 'true',
|
||||
passwordPeppers: string()
|
||||
.array()
|
||||
.parse(isTest ? [nanoid()] : JSON.parse(assertEnv('PASSWORD_PEPPERS'))),
|
||||
passwordIterationCount: number()
|
||||
.min(100)
|
||||
.parse(Number(getEnv('PASSWORD_ITERATION_COUNT', '1000'))),
|
||||
oidc: loadOidcValues(port),
|
||||
});
|
||||
};
|
||||
|
||||
function createEnvSet() {
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let values = loadEnvValues();
|
||||
|
||||
return {
|
||||
values,
|
||||
reload: () => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
values = loadEnvValues();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const envSet = createEnvSet();
|
||||
|
||||
export default envSet;
|
14
packages/core/src/env/consts.ts
vendored
14
packages/core/src/env/consts.ts
vendored
|
@ -1,14 +0,0 @@
|
|||
import { assertEnv, getEnv } from '@silverhand/essentials';
|
||||
|
||||
export const signIn = assertEnv('UI_SIGN_IN_ROUTE');
|
||||
export const isProduction = getEnv('NODE_ENV') === 'production';
|
||||
export const port = Number(getEnv('PORT', '3001'));
|
||||
export enum MountedApps {
|
||||
Api = 'api',
|
||||
Oidc = 'oidc',
|
||||
Console = 'console',
|
||||
}
|
||||
export const developmentUserId = getEnv('DEVELOPMENT_USER_ID');
|
||||
|
||||
// Trusting TLS offloading proxies: https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#trusting-tls-offloading-proxies
|
||||
export const trustingTlsOffloadingProxies = getEnv('TRUSTING_TLS_OFFLOADING_PROXIES') === 'true';
|
|
@ -8,16 +8,15 @@ dotenv.config();
|
|||
/* eslint-disable import/first */
|
||||
import initApp from './app/init';
|
||||
import { initConnectors } from './connectors';
|
||||
import { trustingTlsOffloadingProxies } from './env/consts';
|
||||
import envSet from './env-set';
|
||||
import initI18n from './i18n/init';
|
||||
/* eslint-enable import/first */
|
||||
|
||||
const app = new Koa({
|
||||
proxy: trustingTlsOffloadingProxies,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const app = new Koa({
|
||||
proxy: envSet.values.trustingTlsOffloadingProxies,
|
||||
});
|
||||
await initConnectors();
|
||||
await initI18n();
|
||||
await initApp(app);
|
||||
|
|
|
@ -31,10 +31,7 @@ describe('koaAuth middleware', () => {
|
|||
|
||||
it('should read DEVELOPMENT_USER_ID from env variable first if not production', async () => {
|
||||
// Mock the @/env/consts
|
||||
jest.mock('@/env/consts', () => ({
|
||||
...jest.requireActual('@/env/consts'),
|
||||
developmentUserId: 'foo',
|
||||
}));
|
||||
process.env.DEVELOPMENT_USER_ID = 'foo';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
|
|
@ -4,9 +4,8 @@ import { jwtVerify } from 'jose/jwt/verify';
|
|||
import { MiddlewareType, Request } from 'koa';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { developmentUserId, isProduction } from '@/env/consts';
|
||||
import envSet from '@/env-set';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { publicKey, issuer, adminResource } from '@/oidc/consts';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||
|
@ -33,10 +32,13 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) =
|
|||
};
|
||||
|
||||
const getUserIdFromRequest = async (request: Request) => {
|
||||
const { isProduction, developmentUserId, oidc } = envSet.values;
|
||||
|
||||
if (!isProduction && developmentUserId) {
|
||||
return developmentUserId;
|
||||
}
|
||||
|
||||
const { publicKey, issuer, adminResource } = oidc;
|
||||
const {
|
||||
payload: { sub },
|
||||
} = await jwtVerify(extractBearerTokenFromHeaders(request.headers), publicKey, {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MountedApps } from '@/env/consts';
|
||||
import { MountedApps } from '@/env-set';
|
||||
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
||||
|
||||
import koaSpaProxy from './koa-spa-proxy';
|
||||
|
@ -49,6 +49,8 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
it('production env should overwrite the request path to root if no target ui file are detected', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.PASSWORD_PEPPERS = JSON.stringify(['foo']);
|
||||
process.env.DB_URL = 'some_db_url';
|
||||
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: '/foo',
|
||||
|
@ -63,6 +65,8 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
it('production env should call the static middleware if path hit the ui file directory', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.PASSWORD_PEPPERS = JSON.stringify(['foo']);
|
||||
process.env.DB_URL = 'some_db_url';
|
||||
|
||||
const { default: proxy } = await import('./koa-spa-proxy');
|
||||
const ctx = createContextWithRouteParameters({
|
||||
|
|
|
@ -6,7 +6,7 @@ import proxy from 'koa-proxies';
|
|||
import { IRouterParamContext } from 'koa-router';
|
||||
import serveStatic from 'koa-static';
|
||||
|
||||
import { isProduction, MountedApps } from '@/env/consts';
|
||||
import envSet, { MountedApps } from '@/env-set';
|
||||
|
||||
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
packagePath = 'ui',
|
||||
|
@ -17,7 +17,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
|||
|
||||
const distPath = path.join('..', packagePath, 'dist');
|
||||
|
||||
const spaProxy: Middleware = isProduction
|
||||
const spaProxy: Middleware = envSet.values.isProduction
|
||||
? serveStatic(distPath)
|
||||
: proxy('*', {
|
||||
target: `http://localhost:${port}`,
|
||||
|
@ -45,7 +45,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
|||
return next();
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
if (!envSet.values.isProduction) {
|
||||
return spaProxy(ctx, next);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
import { getEnv } from '@silverhand/essentials';
|
||||
|
||||
import { port } from '@/env/consts';
|
||||
|
||||
export const privateKey = crypto.createPrivateKey(
|
||||
Buffer.from(getEnv('OIDC_PROVIDER_PRIVATE_KEY_BASE64'), 'base64')
|
||||
);
|
||||
export const publicKey = crypto.createPublicKey(privateKey);
|
||||
|
||||
export const issuer = getEnv('OIDC_ISSUER', `http://localhost:${port}/oidc`);
|
||||
export const adminResource = getEnv('ADMIN_RESOURCE', 'https://api.logto.io');
|
||||
|
||||
export const defaultIdTokenTtl = 60 * 60;
|
||||
export const defaultRefreshTokenTtl = 14 * 24 * 60 * 60;
|
|
@ -6,15 +6,16 @@ import Koa from 'koa';
|
|||
import mount from 'koa-mount';
|
||||
import { Provider, errors } from 'oidc-provider';
|
||||
|
||||
import envSet from '@/env-set';
|
||||
import postgresAdapter from '@/oidc/adapter';
|
||||
import { isOriginAllowed, validateCustomClientMetadata } from '@/oidc/utils';
|
||||
import { findResourceByIndicator } from '@/queries/resource';
|
||||
import { findUserById } from '@/queries/user';
|
||||
import { routes } from '@/routes/consts';
|
||||
|
||||
import { issuer, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } from './consts';
|
||||
|
||||
export default async function initOidc(app: Koa): Promise<Provider> {
|
||||
const { issuer, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } = envSet.values.oidc;
|
||||
|
||||
const keys = [await fromKeyLike(privateKey)];
|
||||
const cookieConfig = Object.freeze({
|
||||
sameSite: 'lax',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { signIn } from '@/env/consts';
|
||||
const signIn = '/sign-in';
|
||||
|
||||
export const routes = Object.freeze({
|
||||
signIn: {
|
||||
|
|
|
@ -1,19 +1,11 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
import { UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { assertEnv, repeat } from '@silverhand/essentials';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { number, string } from 'zod';
|
||||
import { repeat } from '@silverhand/essentials';
|
||||
|
||||
import envSet from '@/env-set';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
const peppers = string()
|
||||
.array()
|
||||
.parse(process.env.NODE_ENV === 'test' ? [nanoid()] : JSON.parse(assertEnv('PASSWORD_PEPPERS')));
|
||||
const iterationCount = number()
|
||||
.min(100)
|
||||
.parse(process.env.NODE_ENV === 'test' ? 1000 : Number(assertEnv('PASSWORD_ITERATION_COUNT')));
|
||||
|
||||
export const encryptPassword = (
|
||||
id: string,
|
||||
password: string,
|
||||
|
@ -32,7 +24,9 @@ export const encryptPassword = (
|
|||
(accumulator, current) => accumulator + (current.codePointAt(0) ?? 0),
|
||||
0
|
||||
);
|
||||
const peppers = envSet.values.passwordPeppers;
|
||||
const pepper = peppers[sum % peppers.length];
|
||||
const iterationCount = envSet.values.passwordIterationCount;
|
||||
|
||||
assertThat(pepper, 'password.pepper_not_found');
|
||||
|
||||
|
|
Loading…
Reference in a new issue