changemaker.lite/api/src/services/vaultwarden.client.ts

171 lines
4.7 KiB
TypeScript

import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface VaultwardenUser {
Id: string;
Email: string;
Name: string;
_Status: number; // 0 = enabled, 1 = invited, 2 = disabled
CreatedAt: string;
}
// --- Client ---
class VaultwardenClient {
private sessionCookie: string | null = null;
private sessionExpiresAt = 0;
private get baseUrl(): string {
return env.VAULTWARDEN_URL;
}
private get adminToken(): string {
return env.VAULTWARDEN_ADMIN_TOKEN;
}
get hasCredentials(): boolean {
return !!this.adminToken;
}
/**
* Authenticate to the Vaultwarden admin panel and get a session cookie.
* Session is cached and reused.
*/
private async ensureSession(): Promise<void> {
if (this.sessionCookie && Date.now() < this.sessionExpiresAt) return;
const res = await fetch(`${this.baseUrl}/admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${encodeURIComponent(this.adminToken)}`,
redirect: 'manual', // Don't follow redirect — we need the Set-Cookie header
});
// Vaultwarden returns 303 redirect on successful admin auth
const setCookie = res.headers.get('set-cookie');
if (!setCookie) {
throw new Error(`Vaultwarden admin auth failed (status ${res.status})`);
}
// Extract the session cookie value
const match = setCookie.match(/VW_ADMIN=([^;]+)/);
if (!match) {
throw new Error('Vaultwarden admin auth: no session cookie found');
}
this.sessionCookie = `VW_ADMIN=${match[1]}`;
// Cache session for 20 minutes (Vaultwarden default is 20min)
this.sessionExpiresAt = Date.now() + 19 * 60 * 1000;
logger.debug('Vaultwarden admin session refreshed');
}
/**
* Make an authenticated request to the Vaultwarden admin API
*/
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
await this.ensureSession();
const url = `${this.baseUrl}/admin${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const headers: Record<string, string> = {
Cookie: this.sessionCookie!,
};
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, {
method,
headers,
body: fetchBody,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Vaultwarden API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Health ---
async healthCheck(): Promise<boolean> {
if (!this.hasCredentials) return false;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/alive`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
// --- User Management ---
/** List all users */
async listUsers(): Promise<VaultwardenUser[]> {
return this.request<VaultwardenUser[]>('GET', '/users');
}
/** Find a user by email */
async findUserByEmail(email: string): Promise<VaultwardenUser | null> {
try {
const users = await this.listUsers();
return users.find(u => u.Email.toLowerCase() === email.toLowerCase()) || null;
} catch (err) {
logger.warn('Vaultwarden findUserByEmail failed:', err instanceof Error ? err.message : err);
return null;
}
}
/** Invite a user by email (user sets their own master password) */
async inviteUser(email: string): Promise<void> {
await this.request('POST', '/invite', { email });
}
/** Deactivate a user (disable login without deleting data) */
async deactivateUser(userId: string): Promise<void> {
await this.request('POST', `/users/${userId}/disable`);
}
/** Re-enable a previously deactivated user */
async enableUser(userId: string): Promise<void> {
await this.request('POST', `/users/${userId}/enable`);
}
/** Delete a user and all their data (destructive) */
async deleteUser(userId: string): Promise<void> {
await this.request('DELETE', `/users/${userId}`);
}
}
export const vaultwardenClient = new VaultwardenClient();