feat: integrate OpenBao vault for secret management in BFF

Replace fragile .env backup/restore with Vault-based secret injection.
Secrets are preloaded via --import hook before NestJS modules evaluate,
with a 30s refresh loop and event-driven cache invalidation for services
that read secrets at init (JWT, CSRF, WHMCS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ramirez 2026-02-26 16:27:37 +09:00
parent b206de8dba
commit ed7c167f15
14 changed files with 470 additions and 7 deletions

View File

@ -37,6 +37,7 @@
"@nestjs/common": "^11.1.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.12",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/schedule": "^6.1.0",
"@nestjs/swagger": "^11.2.5",

View File

@ -1,13 +1,17 @@
import { Module } from "@nestjs/common";
import { Module, type DynamicModule, type Type } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE, RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
// Configuration
import { appConfig } from "@bff/core/config/app.config.js";
import { apiRoutes } from "@bff/core/config/router.config.js";
// Vault (conditional — only when VAULT_ADDR is set)
import { VaultModule } from "@bff/core/vault/index.js";
// Core Modules
import { LoggingModule } from "@bff/core/logging/logging.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
@ -46,6 +50,22 @@ import { AddressModule } from "@bff/modules/address/address.module.js";
// System Modules
import { HealthModule } from "@bff/modules/health/health.module.js";
function conditionalVaultImports(): Array<DynamicModule | Type> {
const addr = process.env["VAULT_ADDR"];
const roleId = process.env["VAULT_ROLE_ID"];
const secretId = process.env["VAULT_SECRET_ID"];
const secretPath = process.env["VAULT_SECRET_PATH"] ?? "kv/data/services/portal-backend";
if (!addr || !roleId || !secretId) {
return [];
}
return [
EventEmitterModule.forRoot(),
VaultModule.forRoot({ address: addr, roleId, secretId, secretPath }),
];
}
/**
* Main application module
*
@ -64,6 +84,9 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
ConfigModule.forRoot(appConfig),
ScheduleModule.forRoot(),
// === VAULT (conditional) ===
...conditionalVaultImports(),
// === INFRASTRUCTURE ===
LoggingModule,
SecurityModule,

View File

@ -1,6 +1,12 @@
import { z } from "zod";
export const envSchema = z.object({
// Vault integration (optional — set in systemd EnvironmentFile, not .env)
VAULT_ADDR: z.string().url().optional(),
VAULT_ROLE_ID: z.string().optional(),
VAULT_SECRET_ID: z.string().optional(),
VAULT_SECRET_PATH: z.string().default("kv/data/services/portal-backend"),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
BFF_PORT: z.coerce.number().int().positive().max(65535).default(4000),
LOG_LEVEL: z.enum(["error", "warn", "info", "debug", "trace"]).default("info"),

View File

@ -1,7 +1,9 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { OnEvent } from "@nestjs/event-emitter";
import { Logger } from "nestjs-pino";
import * as crypto from "crypto";
import { VAULT_REFRESH_EVENT } from "@bff/core/vault/vault.constants.js";
export interface CsrfTokenData {
token: string;
@ -32,7 +34,7 @@ export interface CsrfTokenStats {
export class CsrfService {
private readonly tokenExpiry: number;
private readonly secretKey: string;
private secretKey: string;
private readonly cookieName: string;
@ -154,6 +156,18 @@ export class CsrfService {
};
}
@OnEvent(VAULT_REFRESH_EVENT)
handleVaultRefresh({ changedKeys }: { changedKeys: string[] }): void {
if (!changedKeys.includes("CSRF_SECRET_KEY")) return;
const newKey =
this.configService.get<string>("CSRF_SECRET_KEY") ?? process.env["CSRF_SECRET_KEY"];
if (newKey) {
this.secretKey = newKey;
this.logger.log("CSRF secret key updated from Vault");
}
}
private generateSecret(): string {
return crypto.randomBytes(32).toString("base64url");
}

View File

@ -0,0 +1,11 @@
export { VaultModule } from "./vault.module.js";
export { VaultService } from "./vault.service.js";
export { VaultClient } from "./vault.client.js";
export { VAULT_MODULE_OPTIONS, VAULT_REFRESH_EVENT } from "./vault.constants.js";
export type {
VaultConfig,
VaultTokenState,
VaultAuthResponse,
VaultSecretResponse,
VaultRenewResponse,
} from "./vault.types.js";

View File

@ -0,0 +1,57 @@
import type { VaultAuthResponse, VaultSecretResponse, VaultRenewResponse } from "./vault.types.js";
const DEFAULT_TIMEOUT_MS = 5000;
export class VaultClient {
constructor(
private readonly address: string,
private readonly timeoutMs = DEFAULT_TIMEOUT_MS
) {}
async login(roleId: string, secretId: string): Promise<VaultAuthResponse> {
return this.request<VaultAuthResponse>("/v1/auth/approle/login", {
method: "POST",
body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
});
}
async readSecret(path: string, token: string): Promise<VaultSecretResponse> {
return this.request<VaultSecretResponse>(`/v1/${path}`, {
method: "GET",
headers: { "X-Vault-Token": token },
});
}
async renewToken(token: string): Promise<VaultRenewResponse> {
return this.request<VaultRenewResponse>("/v1/auth/token/renew-self", {
method: "POST",
headers: { "X-Vault-Token": token },
});
}
private async request<T>(path: string, init: RequestInit): Promise<T> {
const url = `${this.address}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Vault ${init.method} ${path} failed: ${response.status} ${body}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
}

View File

@ -0,0 +1,2 @@
export const VAULT_MODULE_OPTIONS = Symbol("VAULT_MODULE_OPTIONS");
export const VAULT_REFRESH_EVENT = "vault.secrets.refreshed";

View File

@ -0,0 +1,23 @@
import { type DynamicModule, Global, Module } from "@nestjs/common";
import { VAULT_MODULE_OPTIONS } from "./vault.constants.js";
import { VaultService } from "./vault.service.js";
import type { VaultConfig } from "./vault.types.js";
@Global()
@Module({})
export class VaultModule {
static forRoot(config: VaultConfig): DynamicModule {
return {
module: VaultModule,
providers: [
{
provide: VAULT_MODULE_OPTIONS,
useValue: config,
},
VaultService,
],
exports: [VaultService],
};
}
}

View File

@ -0,0 +1,157 @@
import {
Inject,
Injectable,
Logger,
type OnModuleInit,
type OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { VAULT_MODULE_OPTIONS, VAULT_REFRESH_EVENT } from "./vault.constants.js";
import { VaultClient } from "./vault.client.js";
import type { VaultConfig, VaultTokenState } from "./vault.types.js";
const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
const DEFAULT_TOKEN_RENEW_THRESHOLD = 0.75;
@Injectable()
export class VaultService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(VaultService.name);
private readonly client: VaultClient;
private readonly refreshIntervalMs: number;
private readonly tokenRenewThreshold: number;
private tokenState: VaultTokenState | null = null;
private lastKvVersion: number | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private renewTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
@Inject(VAULT_MODULE_OPTIONS) private readonly config: VaultConfig,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2
) {
this.client = new VaultClient(config.address, config.requestTimeoutMs);
this.refreshIntervalMs = config.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
this.tokenRenewThreshold = config.tokenRenewThreshold ?? DEFAULT_TOKEN_RENEW_THRESHOLD;
}
async onModuleInit(): Promise<void> {
await this.authenticate();
await this.fetchAndApplySecrets();
this.startRefreshLoop();
this.scheduleTokenRenewal();
this.logger.log("Vault secrets loaded and refresh loop started");
}
onModuleDestroy(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
if (this.renewTimer) {
clearTimeout(this.renewTimer);
this.renewTimer = null;
}
}
private async authenticate(): Promise<void> {
const response = await this.client.login(this.config.roleId, this.config.secretId);
this.tokenState = {
clientToken: response.auth.client_token,
leaseDuration: response.auth.lease_duration,
authenticatedAt: Date.now(),
};
this.logger.log(`Authenticated with Vault (TTL: ${response.auth.lease_duration}s)`);
}
private async fetchAndApplySecrets(): Promise<boolean> {
if (!this.tokenState) {
throw new Error("Vault: not authenticated");
}
const response = await this.client.readSecret(
this.config.secretPath,
this.tokenState.clientToken
);
const currentVersion = response.data.metadata.version;
if (this.lastKvVersion !== null && currentVersion === this.lastKvVersion) {
return false;
}
const secrets = response.data.data;
const changedKeys: string[] = [];
for (const [key, value] of Object.entries(secrets)) {
const current = this.configService.get<string>(key);
if (current !== value) {
changedKeys.push(key);
// Update process.env so ConfigService picks up the new value
process.env[key] = value;
}
}
this.lastKvVersion = currentVersion;
if (changedKeys.length > 0) {
this.logger.log(`Vault secrets updated (v${currentVersion}): ${changedKeys.join(", ")}`);
this.eventEmitter.emit(VAULT_REFRESH_EVENT, { changedKeys });
return true;
}
return false;
}
private startRefreshLoop(): void {
this.refreshTimer = setInterval(() => {
void this.refreshSecrets();
}, this.refreshIntervalMs);
}
private async refreshSecrets(): Promise<void> {
try {
await this.fetchAndApplySecrets();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Vault refresh failed, keeping cached values: ${message}`);
}
}
private scheduleTokenRenewal(): void {
if (!this.tokenState) return;
const renewAfterMs = this.tokenState.leaseDuration * 1000 * this.tokenRenewThreshold;
this.renewTimer = setTimeout(() => {
void this.renewToken();
}, renewAfterMs);
}
private async renewToken(): Promise<void> {
if (!this.tokenState) return;
try {
const response = await this.client.renewToken(this.tokenState.clientToken);
this.tokenState = {
clientToken: response.auth.client_token,
leaseDuration: response.auth.lease_duration,
authenticatedAt: Date.now(),
};
this.logger.log(`Vault token renewed (TTL: ${response.auth.lease_duration}s)`);
this.scheduleTokenRenewal();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Vault token renewal failed, re-authenticating: ${message}`);
try {
await this.authenticate();
this.scheduleTokenRenewal();
} catch (authError) {
const authMessage = authError instanceof Error ? authError.message : String(authError);
this.logger.error(`Vault re-authentication failed: ${authMessage}`);
}
}
}
}

View File

@ -0,0 +1,44 @@
export interface VaultConfig {
address: string;
roleId: string;
secretId: string;
secretPath: string;
refreshIntervalMs?: number;
tokenRenewThreshold?: number;
requestTimeoutMs?: number;
}
export interface VaultTokenState {
clientToken: string;
leaseDuration: number;
authenticatedAt: number;
}
export interface VaultAuthResponse {
auth: {
client_token: string;
lease_duration: number;
renewable: boolean;
policies: string[];
};
}
export interface VaultSecretResponse {
data: {
data: Record<string, string>;
metadata: {
version: number;
created_time: string;
deletion_time: string;
destroyed: boolean;
};
};
}
export interface VaultRenewResponse {
auth: {
client_token: string;
lease_duration: number;
renewable: boolean;
};
}

View File

@ -1,6 +1,8 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { OnEvent } from "@nestjs/event-emitter";
import type { WhmcsApiConfig } from "../types/connection.types.js";
import { VAULT_REFRESH_EVENT } from "@bff/core/vault/vault.constants.js";
/**
* Service for managing WHMCS API configuration
@ -8,7 +10,8 @@ import type { WhmcsApiConfig } from "../types/connection.types.js";
*/
@Injectable()
export class WhmcsConfigService {
private readonly config: WhmcsApiConfig;
private readonly logger = new Logger(WhmcsConfigService.name);
private config: WhmcsApiConfig;
constructor(private readonly configService: ConfigService) {
this.config = this.loadConfiguration();
@ -44,6 +47,15 @@ export class WhmcsConfigService {
}
}
@OnEvent(VAULT_REFRESH_EVENT)
handleVaultRefresh({ changedKeys }: { changedKeys: string[] }): void {
const whmcsKeys = ["WHMCS_API_IDENTIFIER", "WHMCS_API_SECRET", "WHMCS_BASE_URL"];
if (!changedKeys.some(k => whmcsKeys.includes(k))) return;
this.config = this.loadConfiguration();
this.logger.log("WHMCS config reloaded from Vault");
}
/**
* Load configuration from environment variables
*/

View File

@ -1,16 +1,21 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { OnEvent } from "@nestjs/event-emitter";
import { SignJWT, decodeJwt, jwtVerify, errors, type JWTPayload } from "jose";
import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
import { VAULT_REFRESH_EVENT } from "@bff/core/vault/vault.constants.js";
@Injectable()
export class JoseJwtService {
private readonly signingKey: Uint8Array;
private readonly verificationKeys: Uint8Array[];
private readonly logger = new Logger(JoseJwtService.name);
private signingKey: Uint8Array;
private verificationKeys: Uint8Array[];
private readonly issuer: string | undefined;
private readonly audience: string | string[] | undefined;
private readonly configService: ConfigService;
constructor(configService: ConfigService) {
this.configService = configService;
const secret = configService.get<string>("JWT_SECRET");
if (!secret) {
throw new Error("JWT_SECRET is required in environment variables");
@ -136,4 +141,22 @@ export class JoseJwtService {
return null;
}
}
@OnEvent(VAULT_REFRESH_EVENT)
handleVaultRefresh({ changedKeys }: { changedKeys: string[] }): void {
if (!changedKeys.includes("JWT_SECRET")) return;
const secret = this.configService.get<string>("JWT_SECRET") ?? process.env["JWT_SECRET"];
if (!secret) return;
this.signingKey = new TextEncoder().encode(secret);
const previousRaw = this.configService.get<string | undefined>("JWT_SECRET_PREVIOUS");
const previousSecrets = this.parsePreviousSecrets(previousRaw).filter(s => s !== secret);
this.verificationKeys = [
this.signingKey,
...previousSecrets.map(s => new TextEncoder().encode(s)),
];
this.logger.log("JWT signing/verification keys re-derived from Vault");
}
}

View File

@ -0,0 +1,64 @@
/**
* Vault secret preloader runs via `node --import ./dist/vault-preload.js`
* BEFORE the main entry point loads. This ensures process.env is populated
* before any NestJS modules evaluate (ConfigModule.forRoot Zod validate).
*
* Skipped when VAULT_ADDR is not set (local dev).
*/
const addr = process.env["VAULT_ADDR"];
const roleId = process.env["VAULT_ROLE_ID"];
const secretId = process.env["VAULT_SECRET_ID"];
const secretPath = process.env["VAULT_SECRET_PATH"] ?? "kv/data/services/portal-backend";
if (addr && roleId && secretId) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
// AppRole login
const authRes = await fetch(`${addr}/v1/auth/approle/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
signal: controller.signal,
});
if (!authRes.ok) {
throw new Error(`Vault login failed: ${authRes.status} ${await authRes.text()}`);
}
const authData = (await authRes.json()) as { auth: { client_token: string } };
const token = authData.auth.client_token;
// Read secrets
const secretRes = await fetch(`${addr}/v1/${secretPath}`, {
method: "GET",
headers: { "Content-Type": "application/json", "X-Vault-Token": token },
signal: controller.signal,
});
if (!secretRes.ok) {
throw new Error(`Vault read failed: ${secretRes.status} ${await secretRes.text()}`);
}
const secretData = (await secretRes.json()) as {
data: { data: Record<string, string>; metadata: { version: number } };
};
const secrets = secretData.data.data;
for (const [key, value] of Object.entries(secrets)) {
process.env[key] = value;
}
console.warn(
`[Vault] Secrets preloaded (${Object.keys(secrets).length} keys, v${secretData.data.metadata.version})`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Vault] FATAL: Failed to preload secrets: ${message}`);
process.exit(1);
} finally {
clearTimeout(timeout);
}
}

26
pnpm-lock.yaml generated
View File

@ -97,6 +97,9 @@ importers:
"@nestjs/core":
specifier: ^11.1.12
version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
"@nestjs/event-emitter":
specifier: ^3.0.1
version: 3.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)
"@nestjs/platform-express":
specifier: ^11.1.12
version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)
@ -1880,6 +1883,15 @@ packages:
"@nestjs/websockets":
optional: true
"@nestjs/event-emitter@3.0.1":
resolution:
{
integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==,
}
peerDependencies:
"@nestjs/common": ^10.0.0 || ^11.0.0
"@nestjs/core": ^10.0.0 || ^11.0.0
"@nestjs/mapped-types@2.1.0":
resolution:
{
@ -4829,6 +4841,12 @@ packages:
}
engines: { node: ">= 0.6" }
eventemitter2@6.4.9:
resolution:
{
integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==,
}
eventemitter3@5.0.4:
resolution:
{
@ -9444,6 +9462,12 @@ snapshots:
optionalDependencies:
"@nestjs/platform-express": 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)
"@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)":
dependencies:
"@nestjs/common": 11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
"@nestjs/core": 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
eventemitter2: 6.4.9
"@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)":
dependencies:
"@nestjs/common": 11.1.12(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -11240,6 +11264,8 @@ snapshots:
etag@1.8.1: {}
eventemitter2@6.4.9: {}
eventemitter3@5.0.4: {}
events-universal@1.0.1: