Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Implements secure credential encryption using OpenBao Transit API with automatic fallback to AES-256-GCM when OpenBao is unavailable. Features: - AppRole authentication with automatic token renewal at 50% TTL - Transit encrypt/decrypt with 4 named keys - Automatic fallback to CryptoService when OpenBao unavailable - Auto-detection of ciphertext format (vault:v1: vs AES) - Request timeout protection (5s default) - Health indicator for monitoring - Backward compatible with existing AES-encrypted data Security: - ERROR-level logging for fallback - Proper error propagation (no silent failures) - Request timeouts prevent hung operations - Secure credential file reading Migrations: - Account encryption middleware uses VaultService - Uses TransitKey.ACCOUNT_TOKENS for OAuth tokens - Backward compatible with existing encrypted data Tests: 56 tests passing (36 VaultService + 20 middleware) Closes #353 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
/**
|
|
* Account Encryption Middleware Tests
|
|
*
|
|
* Tests transparent encryption/decryption of OAuth tokens in Account model
|
|
* using Prisma middleware.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { CryptoService } from "../federation/crypto.service";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
|
|
|
|
describe("AccountEncryptionMiddleware", () => {
|
|
let mockPrisma: any;
|
|
let cryptoService: CryptoService;
|
|
let mockConfigService: Partial<ConfigService>;
|
|
let middlewareFunction: any;
|
|
|
|
beforeAll(() => {
|
|
// Mock ConfigService with a valid test encryption key
|
|
mockConfigService = {
|
|
get: vi.fn((key: string) => {
|
|
if (key === "ENCRYPTION_KEY") {
|
|
// Valid 64-character hex string (32 bytes)
|
|
return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
|
|
cryptoService = new CryptoService(mockConfigService as ConfigService);
|
|
|
|
// Create a mock Prisma client
|
|
mockPrisma = {
|
|
$use: vi.fn((fn) => {
|
|
middlewareFunction = fn;
|
|
}),
|
|
};
|
|
|
|
// Register the middleware
|
|
registerAccountEncryptionMiddleware(mockPrisma, cryptoService);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// No cleanup needed for mocks
|
|
});
|
|
|
|
describe("Encryption on Create", () => {
|
|
it("should encrypt accessToken on account creation", async () => {
|
|
const plainAccessToken = "test-access-token-12345";
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "github",
|
|
accessToken: plainAccessToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
// Middleware should modify args
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
// Verify accessToken is encrypted (starts with hex:hex:hex format)
|
|
expect(result.args.data.accessToken).toBeDefined();
|
|
expect(result.args.data.accessToken).not.toBe(plainAccessToken);
|
|
expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should encrypt refreshToken on account creation", async () => {
|
|
const plainRefreshToken = "test-refresh-token-67890";
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "github",
|
|
refreshToken: plainRefreshToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.refreshToken).toBeDefined();
|
|
expect(result.args.data.refreshToken).not.toBe(plainRefreshToken);
|
|
expect(result.args.data.refreshToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should encrypt idToken on account creation", async () => {
|
|
const plainIdToken = "test-id-token-abcdef";
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "oauth",
|
|
idToken: plainIdToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.idToken).toBeDefined();
|
|
expect(result.args.data.idToken).not.toBe(plainIdToken);
|
|
expect(result.args.data.idToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should encrypt all three tokens when present", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "oauth",
|
|
accessToken: "access-123",
|
|
refreshToken: "refresh-456",
|
|
idToken: "id-789",
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
expect(result.args.data.refreshToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
expect(result.args.data.idToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should handle null tokens gracefully", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "github",
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.accessToken).toBeNull();
|
|
expect(result.args.data.refreshToken).toBeNull();
|
|
expect(result.args.data.idToken).toBeNull();
|
|
});
|
|
|
|
it("should handle undefined tokens gracefully", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
userId: "test-user-id",
|
|
accountId: "test-account-id",
|
|
providerId: "github",
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.accessToken).toBeUndefined();
|
|
expect(result.args.data.refreshToken).toBeUndefined();
|
|
expect(result.args.data.idToken).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Encryption on Update", () => {
|
|
it("should encrypt accessToken on account update", async () => {
|
|
const plainAccessToken = "updated-access-token";
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "update" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
data: {
|
|
accessToken: plainAccessToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.accessToken).toBeDefined();
|
|
expect(result.args.data.accessToken).not.toBe(plainAccessToken);
|
|
expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should handle updateMany action", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "updateMany" as const,
|
|
args: {
|
|
where: { providerId: "github" },
|
|
data: {
|
|
accessToken: "new-token",
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
|
|
it("should not encrypt already encrypted tokens (idempotent)", async () => {
|
|
// Simulate a token that's already encrypted
|
|
const encryptedToken = cryptoService.encrypt("original-token");
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "update" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
data: {
|
|
accessToken: encryptedToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
// Should remain unchanged if already encrypted
|
|
expect(result.args.data.accessToken).toBe(encryptedToken);
|
|
});
|
|
|
|
it("should handle upsert action (encrypt both create and update)", async () => {
|
|
const plainCreateToken = "create-token";
|
|
const plainUpdateToken = "update-token";
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "upsert" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
create: {
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: plainCreateToken,
|
|
},
|
|
update: {
|
|
accessToken: plainUpdateToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
// Both create and update data should be encrypted
|
|
expect(result.args.create.accessToken).toBeDefined();
|
|
expect(result.args.create.accessToken).not.toBe(plainCreateToken);
|
|
expect(result.args.create.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
|
|
expect(result.args.update.accessToken).toBeDefined();
|
|
expect(result.args.update.accessToken).not.toBe(plainUpdateToken);
|
|
expect(result.args.update.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
|
});
|
|
});
|
|
|
|
describe("Decryption on Read", () => {
|
|
it("should decrypt accessToken on findUnique", async () => {
|
|
const plainToken = "my-access-token";
|
|
const encryptedToken = cryptoService.encrypt(plainToken);
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
// Mock database returning encrypted data with encryptionVersion
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: encryptedToken,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: "aes",
|
|
}));
|
|
|
|
// Call middleware - it should decrypt the result
|
|
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
|
|
|
expect(mockNext).toHaveBeenCalledWith(mockParams);
|
|
expect(result.accessToken).toBe(plainToken); // Decrypted by middleware
|
|
expect(result.encryptionVersion).toBe("aes");
|
|
});
|
|
|
|
it("should decrypt all tokens on findMany", async () => {
|
|
const plainAccess = "access-token";
|
|
const plainRefresh = "refresh-token";
|
|
const plainId = "id-token";
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findMany" as const,
|
|
args: {
|
|
where: { providerId: "github" },
|
|
},
|
|
};
|
|
|
|
// Mock database returning multiple encrypted records
|
|
const mockNext = vi.fn(async () => [
|
|
{
|
|
id: "account-1",
|
|
userId: "user-id",
|
|
accountId: "account-1",
|
|
providerId: "github",
|
|
accessToken: cryptoService.encrypt(plainAccess),
|
|
refreshToken: cryptoService.encrypt(plainRefresh),
|
|
idToken: cryptoService.encrypt(plainId),
|
|
encryptionVersion: "aes",
|
|
},
|
|
]);
|
|
|
|
const result = (await middlewareFunction(mockParams, mockNext)) as any[];
|
|
|
|
expect(mockNext).toHaveBeenCalledWith(mockParams);
|
|
expect(result[0].accessToken).toBe(plainAccess);
|
|
expect(result[0].refreshToken).toBe(plainRefresh);
|
|
expect(result[0].idToken).toBe(plainId);
|
|
});
|
|
|
|
it("should handle null tokens on read", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: null,
|
|
}));
|
|
|
|
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
|
|
|
expect(result.accessToken).toBeNull();
|
|
expect(result.refreshToken).toBeNull();
|
|
expect(result.idToken).toBeNull();
|
|
});
|
|
|
|
it("should handle legacy plaintext tokens (backward compatibility)", async () => {
|
|
// Simulate old data without encryptionVersion field
|
|
const plaintextToken = "legacy-plaintext-token";
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: plaintextToken,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: null, // No encryption version = plaintext
|
|
}));
|
|
|
|
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
|
|
|
// Should pass through unchanged (no encryptionVersion)
|
|
expect(result.accessToken).toBe(plaintextToken);
|
|
});
|
|
|
|
it("should throw error on vault ciphertext when OpenBao unavailable", async () => {
|
|
// Simulate vault Transit encryption format when OpenBao is unavailable
|
|
const vaultCiphertext = "vault:v1:base64encodeddata";
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "oauth",
|
|
accessToken: vaultCiphertext,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: "vault", // vault encryption
|
|
}));
|
|
|
|
// Should throw error because VaultService can't decrypt vault:v1: without OpenBao
|
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
|
"Failed to decrypt account credentials"
|
|
);
|
|
});
|
|
|
|
it("should use encryptionVersion as primary discriminator", async () => {
|
|
// Even if token looks like AES format, should not decrypt if encryptionVersion != 'aes'
|
|
const fakeEncryptedToken = "abc123:def456:789ghi"; // Looks like AES format
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: fakeEncryptedToken,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: null, // No encryption version
|
|
}));
|
|
|
|
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
|
|
|
// Should NOT attempt to decrypt (encryptionVersion is null)
|
|
expect(result.accessToken).toBe(fakeEncryptedToken);
|
|
});
|
|
|
|
it("should throw error on corrupted encrypted data", async () => {
|
|
// Test with malformed/corrupted encrypted token
|
|
const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: corruptedToken,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: "aes", // Marked as encrypted
|
|
}));
|
|
|
|
// Should throw error - decryption failures are now propagated to prevent silent corruption
|
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
|
"Failed to decrypt account credentials"
|
|
);
|
|
});
|
|
|
|
it("should throw error on completely malformed encrypted format", async () => {
|
|
// Test with data that doesn't match expected format at all
|
|
const malformedToken = "this:is:not:valid:encrypted:data:too:many:parts";
|
|
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
},
|
|
};
|
|
|
|
const mockNext = vi.fn(async () => ({
|
|
id: "account-id",
|
|
userId: "user-id",
|
|
accountId: "account-id",
|
|
providerId: "github",
|
|
accessToken: malformedToken,
|
|
refreshToken: null,
|
|
idToken: null,
|
|
encryptionVersion: "aes",
|
|
}));
|
|
|
|
// Should throw error - malformed data cannot be decrypted
|
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
|
"Failed to decrypt account credentials"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Non-Account Models", () => {
|
|
it("should not process other models", async () => {
|
|
const mockParams = {
|
|
model: "User",
|
|
action: "create" as const,
|
|
args: {
|
|
data: {
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
// Should pass through unchanged
|
|
expect(result.args.data).toEqual({
|
|
email: "test@example.com",
|
|
name: "Test User",
|
|
});
|
|
});
|
|
|
|
it("should not process Account queries without token fields", async () => {
|
|
const mockParams = {
|
|
model: "Account",
|
|
action: "findUnique" as const,
|
|
args: {
|
|
where: { id: "account-id" },
|
|
select: {
|
|
id: true,
|
|
providerId: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await callMiddleware(mockPrisma, mockParams);
|
|
|
|
// Should pass through without modification
|
|
expect(result.args.select).toEqual({
|
|
id: true,
|
|
providerId: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helper function to simulate middleware execution for write operations
|
|
async function callMiddleware(client: any, params: any) {
|
|
if (!middlewareFunction) {
|
|
throw new Error("Middleware not registered");
|
|
}
|
|
|
|
// Call middleware with a mock next function that returns the params unchanged
|
|
// This is useful for testing write operations where we check if data was encrypted
|
|
return middlewareFunction(params, async (p: any) => p);
|
|
}
|
|
});
|