feat(phase-2): workstream 1 — HashiCorp Vault credential storage

Vault is optional — server falls back to bcrypt (Phase 1 behaviour)
when VAULT_ADDR is not set. Full coexistence: existing bcrypt credentials
continue to work until rotated.

Changes:
- src/vault/VaultClient.ts — wraps node-vault KV v2; writeSecret,
  readSecret, verifySecret (constant-time), deleteSecret
- src/db/migrations/005_add_vault_path.sql — vault_path column on credentials
- CredentialRepository — createWithVaultPath, updateVaultPath methods
- CredentialService — routes generate/rotate through Vault when configured;
  bcrypt path unchanged
- OAuth2Service — verifies via Vault when vaultPath set, bcrypt otherwise
- src/app.ts — createVaultClientFromEnv() wired into service layer
- ICredentialRow — vaultPath field added
- docs/devops/environment-variables.md — VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- docs/devops/vault-setup.md — dev quickstart, production config, migration guide
- tests: 33/33 unit tests pass (VaultClient + CredentialService Vault path)
- node-vault + @types/node-vault installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:02:33 +00:00
parent 7593bfe1c1
commit 90a4addb21
14 changed files with 1064 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import { TokenRepository } from '../repositories/TokenRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import {
ITokenPayload,
ITokenResponse,
@@ -44,6 +45,7 @@ export class OAuth2Service {
* @param auditService - The audit log service.
* @param privateKey - PEM-encoded RSA private key for signing tokens.
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
*/
constructor(
private readonly tokenRepository: TokenRepository,
@@ -52,6 +54,7 @@ export class OAuth2Service {
private readonly auditService: AuditService,
private readonly privateKey: string,
private readonly publicKey: string,
private readonly vaultClient: VaultClient | null = null,
) {}
/**
@@ -101,12 +104,25 @@ export class OAuth2Service {
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
const matches = await verifySecret(clientSecret, credRow.secretHash);
// Check expiry before attempting secret verification
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
let matches: boolean;
if (credRow.vaultPath !== null && this.vaultClient !== null) {
// Phase 2: verify against Vault-stored secret
matches = await this.vaultClient.verifySecret(
clientId,
credRow.credentialId,
clientSecret,
);
} else {
// Phase 1: verify against bcrypt hash
matches = await verifySecret(clientSecret, credRow.secretHash);
}
if (matches) {
// Check if credential is expired
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
credentialVerified = true;
break;
}