- Updated all package.json name fields and dependency references - Updated all TypeScript/JavaScript imports - Updated .woodpecker/publish.yml filters and registry paths - Updated tools/install.sh scope default - Updated .npmrc registry paths (worktree + host) - Enhanced update-checker.ts with checkForAllUpdates() multi-package support - Updated CLI update command to show table of all packages - Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand - Marked checkForUpdate() with @deprecated JSDoc Closes #391
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
import type { Db } from '@mosaicstack/db';
|
|
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
|
import { DB } from '../database/database.module.js';
|
|
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
|
|
|
const ALGORITHM = 'aes-256-gcm';
|
|
const IV_LENGTH = 12; // 96-bit IV for GCM
|
|
const TAG_LENGTH = 16; // 128-bit auth tag
|
|
|
|
/**
|
|
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
|
* The secret is assumed to be set in the environment.
|
|
*/
|
|
function deriveEncryptionKey(): Buffer {
|
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
if (!secret) {
|
|
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
|
}
|
|
return createHash('sha256').update(secret).digest();
|
|
}
|
|
|
|
/**
|
|
* Encrypt a plain-text value using AES-256-GCM.
|
|
* Output format: base64(iv + authTag + ciphertext)
|
|
*/
|
|
function encrypt(plaintext: string): string {
|
|
const key = deriveEncryptionKey();
|
|
const iv = randomBytes(IV_LENGTH);
|
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
|
|
const combined = Buffer.concat([iv, authTag, encrypted]);
|
|
return combined.toString('base64');
|
|
}
|
|
|
|
/**
|
|
* Decrypt a value encrypted by `encrypt()`.
|
|
* Throws on authentication failure (tampered data).
|
|
*/
|
|
function decrypt(encoded: string): string {
|
|
const key = deriveEncryptionKey();
|
|
const combined = Buffer.from(encoded, 'base64');
|
|
|
|
const iv = combined.subarray(0, IV_LENGTH);
|
|
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
|
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
return decrypted.toString('utf8');
|
|
}
|
|
|
|
@Injectable()
|
|
export class ProviderCredentialsService {
|
|
private readonly logger = new Logger(ProviderCredentialsService.name);
|
|
|
|
constructor(@Inject(DB) private readonly db: Db) {}
|
|
|
|
/**
|
|
* Encrypt and store (or update) a credential for the given user + provider.
|
|
* Uses an upsert pattern: one row per (userId, provider).
|
|
*/
|
|
async store(
|
|
userId: string,
|
|
provider: string,
|
|
type: 'api_key' | 'oauth_token',
|
|
value: string,
|
|
metadata?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const encryptedValue = encrypt(value);
|
|
|
|
await this.db
|
|
.insert(providerCredentials)
|
|
.values({
|
|
userId,
|
|
provider,
|
|
credentialType: type,
|
|
encryptedValue,
|
|
metadata: metadata ?? null,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [providerCredentials.userId, providerCredentials.provider],
|
|
set: {
|
|
credentialType: type,
|
|
encryptedValue,
|
|
metadata: metadata ?? null,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
this.logger.log(`Credential stored for user=${userId} provider=${provider}`);
|
|
}
|
|
|
|
/**
|
|
* Decrypt and return the plain-text credential value for the given user + provider.
|
|
* Returns null if no credential is stored.
|
|
*/
|
|
async retrieve(userId: string, provider: string): Promise<string | null> {
|
|
const rows = await this.db
|
|
.select()
|
|
.from(providerCredentials)
|
|
.where(
|
|
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
|
|
)
|
|
.limit(1);
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
const row = rows[0]!;
|
|
|
|
// Skip expired OAuth tokens
|
|
if (row.expiresAt && row.expiresAt < new Date()) {
|
|
this.logger.warn(`Credential for user=${userId} provider=${provider} has expired`);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return decrypt(row.encryptedValue);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete the stored credential for the given user + provider.
|
|
*/
|
|
async remove(userId: string, provider: string): Promise<void> {
|
|
await this.db
|
|
.delete(providerCredentials)
|
|
.where(
|
|
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
|
|
);
|
|
|
|
this.logger.log(`Credential removed for user=${userId} provider=${provider}`);
|
|
}
|
|
|
|
/**
|
|
* List all providers for which the user has stored credentials.
|
|
* Never returns decrypted values.
|
|
*/
|
|
async listProviders(userId: string): Promise<ProviderCredentialSummaryDto[]> {
|
|
const rows = await this.db
|
|
.select({
|
|
provider: providerCredentials.provider,
|
|
credentialType: providerCredentials.credentialType,
|
|
expiresAt: providerCredentials.expiresAt,
|
|
metadata: providerCredentials.metadata,
|
|
createdAt: providerCredentials.createdAt,
|
|
updatedAt: providerCredentials.updatedAt,
|
|
})
|
|
.from(providerCredentials)
|
|
.where(eq(providerCredentials.userId, userId));
|
|
|
|
return rows.map((row) => ({
|
|
provider: row.provider,
|
|
credentialType: row.credentialType,
|
|
exists: true,
|
|
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
metadata: row.metadata as Record<string, unknown> | null,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
}));
|
|
}
|
|
}
|