diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index e22ef8085..b156e8163 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -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', diff --git a/packages/core/jest.global-setup.js b/packages/core/jest.global-setup.js new file mode 100644 index 000000000..c61d467f0 --- /dev/null +++ b/packages/core/jest.global-setup.js @@ -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 */ diff --git a/packages/core/jest.setup.js b/packages/core/jest.setup.js index 2a9545d0d..d9734d8ad 100644 --- a/packages/core/jest.setup.js +++ b/packages/core/jest.setup.js @@ -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 */ diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index b7206e27a..dc8a99e41 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -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 { ); 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, () => { diff --git a/packages/core/src/database/pool.ts b/packages/core/src/database/pool.ts index ae69376ad..ff6103542 100644 --- a/packages/core/src/database/pool.ts +++ b/packages/core/src/database/pool.ts @@ -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; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts new file mode 100644 index 000000000..34557b67c --- /dev/null +++ b/packages/core/src/env-set/index.ts @@ -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 => { + 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; diff --git a/packages/core/src/env/consts.ts b/packages/core/src/env/consts.ts deleted file mode 100644 index 3b17b484e..000000000 --- a/packages/core/src/env/consts.ts +++ /dev/null @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2510f21cb..d7cac4049 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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); diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index 85c3b7902..d5c43f2d4 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -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 */ diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index d683c1078..cab2c1c31 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -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 = @@ -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, { diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts index 6ad359d52..8e35fb4cb 100644 --- a/packages/core/src/middleware/koa-spa-proxy.test.ts +++ b/packages/core/src/middleware/koa-spa-proxy.test.ts @@ -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({ diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index 43edf7039..e3b1fb827 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -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( packagePath = 'ui', @@ -17,7 +17,7 @@ export default function koaSpaProxy { + const { issuer, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } = envSet.values.oidc; + const keys = [await fromKeyLike(privateKey)]; const cookieConfig = Object.freeze({ sameSite: 'lax', diff --git a/packages/core/src/routes/consts.ts b/packages/core/src/routes/consts.ts index 6b484dff9..5daf0f88d 100644 --- a/packages/core/src/routes/consts.ts +++ b/packages/core/src/routes/consts.ts @@ -1,4 +1,4 @@ -import { signIn } from '@/env/consts'; +const signIn = '/sign-in'; export const routes = Object.freeze({ signIn: { diff --git a/packages/core/src/utils/password.ts b/packages/core/src/utils/password.ts index 67ba16b19..8c33f14d2 100644 --- a/packages/core/src/utils/password.ts +++ b/packages/core/src/utils/password.ts @@ -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');