fix(config): resolve signing secrets lazily so load order cannot split them

Found by a runtime probe against a locally built API: login returned 200 but the
access token it issued was rejected on the next request, because the process was
signing with one secret and verifying with another.

apps/api/src/main.ts calls loadEnv() (which reads .env.local and friends) only
after its imports have run, so @gauzy/config can be evaluated while JWT_SECRET is
still unset. With the published literal as the fallback that was invisible: the
early copy and any later copy both ended up on 'secretKey'. Once an unset secret
became a per-process random value, the early copy generated one while a copy
imported after loadEnv() read the configured value — so tokens signed by one
never verified in the other, and every authenticated request 401'd.

The four secrets are now getters on `environment` / `environment.prod` /
`defaultConfiguration.authOptions`, so each is read at first use, after the env
files are loaded. Adds the regression test for exactly that order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ruslan Konviser
2026-09-20 11:34:43 +02:00
co-authored by Claude Opus 5
parent 6853ceba3d
commit 21e0bfd649
4 changed files with 63 additions and 11 deletions
+9 -3
View File
@@ -91,10 +91,16 @@ export const defaultConfiguration: ApplicationPluginConfig = {
User: []
},
authOptions: {
// Same resolver as `environment`, so both surfaces agree on the per-process value (GHSA-39j7-x845-4w3c).
expressSessionSecret: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'),
// Same resolver as `environment`, so both surfaces agree on the per-process value, and read
// lazily for the same reason: the API loads its env files after its imports have run, so an
// eagerly resolved secret would be decided before `.env.local` exists (GHSA-39j7-x845-4w3c).
get expressSessionSecret(): string {
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy');
},
userPasswordBcryptSaltRounds: 12,
jwtSecret: resolveSecret('JWT_SECRET', 'secretKey')
get jwtSecret(): string {
return resolveSecret('JWT_SECRET', 'secretKey');
}
},
assetOptions: {
assetPath: assetPath,
@@ -67,19 +67,30 @@ export const environment: IEnvironment = {
LOG_LEVEL: 'debug'
},
EXPRESS_SESSION_SECRET: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'), // Never a published literal outside DEMO (GHSA-39j7-x845-4w3c)
// Getters, so the value is read when it is first used rather than when this module is imported:
// the API loads its env files after its imports have run, so an eagerly read secret can be
// decided before they are loaded (GHSA-39j7-x845-4w3c).
get EXPRESS_SESSION_SECRET(): string {
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'); // Never a published literal outside DEMO
},
USER_PASSWORD_BCRYPT_SALT_ROUNDS: 12,
JWT_SECRET: process.env.JWT_SECRET!, // Validated at startup — must be set in production
get JWT_SECRET(): string {
return process.env.JWT_SECRET!; // Validated at startup — must be set in production
},
JWT_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_TOKEN_EXPIRATION_TIME) || 86400 * 1, // default JWT token expire time (1 day)
JWT_REFRESH_TOKEN_SECRET: process.env.JWT_REFRESH_TOKEN_SECRET!, // Validated at startup — must be set in production
get JWT_REFRESH_TOKEN_SECRET(): string {
return process.env.JWT_REFRESH_TOKEN_SECRET!; // Validated at startup — must be set in production
},
JWT_REFRESH_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default JWT refresh token expire time (7 days)
/**
* Email verification options
*/
JWT_VERIFICATION_TOKEN_SECRET: process.env.JWT_VERIFICATION_TOKEN_SECRET!, // Validated at startup — must be set in production
get JWT_VERIFICATION_TOKEN_SECRET(): string {
return process.env.JWT_VERIFICATION_TOKEN_SECRET!; // Validated at startup — must be set in production
},
JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_VERIFICATION_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default verification expire token time (7 days)
/**
@@ -32,20 +32,35 @@ export const environment: IEnvironment = {
* Token-signing and session secrets never fall back to a published literal outside DEMO: unset
* means a random per-process value (and a refused boot in production). See resolveSecret()
* (GHSA-39j7-x845-4w3c).
*
* They are GETTERS, so the value is resolved when it is first used rather than when this module
* is imported. `apps/api/src/main.ts` calls `loadEnv()` (which reads `.env.local` and friends)
* only AFTER its imports have run, so an eagerly resolved secret would be decided before those
* files are loaded — this copy would generate a random value while a copy imported later saw the
* configured one, and tokens signed by one would not verify in the other. With the published
* literal that clash was invisible, because both copies ended up on the same literal.
*/
EXPRESS_SESSION_SECRET: resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy'),
get EXPRESS_SESSION_SECRET(): string {
return resolveSecret('EXPRESS_SESSION_SECRET', 'gauzy');
},
USER_PASSWORD_BCRYPT_SALT_ROUNDS: 12,
JWT_SECRET: resolveSecret('JWT_SECRET', 'secretKey'),
get JWT_SECRET(): string {
return resolveSecret('JWT_SECRET', 'secretKey');
},
JWT_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_TOKEN_EXPIRATION_TIME) || 86400 * 1, // default JWT token expire time (1 day)
JWT_REFRESH_TOKEN_SECRET: resolveSecret('JWT_REFRESH_TOKEN_SECRET', 'refreshSecretKey'),
get JWT_REFRESH_TOKEN_SECRET(): string {
return resolveSecret('JWT_REFRESH_TOKEN_SECRET', 'refreshSecretKey');
},
JWT_REFRESH_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default JWT refresh token expire time (7 days)
/**
* Email verification options
*/
JWT_VERIFICATION_TOKEN_SECRET: resolveSecret('JWT_VERIFICATION_TOKEN_SECRET', 'verificationSecretKey'),
get JWT_VERIFICATION_TOKEN_SECRET(): string {
return resolveSecret('JWT_VERIFICATION_TOKEN_SECRET', 'verificationSecretKey');
},
JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: parseInt(process.env.JWT_VERIFICATION_TOKEN_EXPIRATION_TIME) || 86400 * 7, // default verification expire token time (7 days)
/**
@@ -194,6 +194,26 @@ describe('resolveSecret (GHSA-39j7-x845-4w3c)', () => {
expect(defaultConfiguration.authOptions.jwtSecret).toBe(environment.JWT_SECRET);
expect(defaultConfiguration.authOptions.expressSessionSecret).toBe(environment.EXPRESS_SESSION_SECRET);
});
// The API's entry point calls loadEnv() (which reads .env.local and friends) only AFTER its
// imports have run, so this module can be evaluated while JWT_SECRET is still unset. Resolving
// eagerly there decided the secret too early: this copy generated a random value, a copy
// imported after loadEnv() read the configured one, and tokens signed with one were rejected by
// the other — a 401 on every authenticated request. The published literal used to hide it,
// because both copies then landed on the same literal.
it('reads a secret configured AFTER this module was imported (load order)', () => {
const { environment } = loadFresh<typeof import('./environment')>('./environment');
const { defaultConfiguration } = loadFresh<typeof import('../default-config')>('../default-config');
// Imported with nothing set: a random per-process value, as above.
expect(environment.JWT_SECRET).toMatch(/^[0-9a-f]{128}$/);
// ...then the env file is loaded, exactly as loadEnv() does at startup.
process.env.JWT_SECRET = 'configured-by-load-env';
expect(environment.JWT_SECRET).toBe('configured-by-load-env');
expect(defaultConfiguration.authOptions.jwtSecret).toBe('configured-by-load-env');
});
});
it('KNOWN_DEFAULT_SECRETS covers every literal the repository has shipped as a secret', () => {