171 lines
4.7 KiB
TypeScript
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();
|