0
Fork 0
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:
Gao Sun 2022-04-20 14:14:37 +08:00 committed by GitHub
parent 3dc07312a0
commit 08ce66f317
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 142 additions and 69 deletions

View file

@ -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',

View 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 */

View file

@ -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 */

View file

@ -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, () => {

View file

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

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

View file

@ -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';

View file

@ -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);

View file

@ -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 */

View file

@ -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, {

View file

@ -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({

View file

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

View file

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

View file

@ -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',

View file

@ -1,4 +1,4 @@
import { signIn } from '@/env/consts';
const signIn = '/sign-in';
export const routes = Object.freeze({
signIn: {

View file

@ -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');