import { getVaultServerUrl } from './sidecar-server'; // Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via // the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master // password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password // hash), exactly what Vaultwarden itself checks. export type ConnectResult = { ok: true; data: Record } | { ok: false; status: number; error: string }; async function postConnectToken(form: URLSearchParams): Promise { const base = getVaultServerUrl(); if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' }; let res: Response; try { res = await fetch(`${base}/identity/connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), signal: AbortSignal.timeout(30_000), }); } catch { return { ok: false, status: 502, error: 'vault upstream unreachable' }; } const data = (await res.json().catch(() => ({}))) as Record; if (!res.ok) { // Vaultwarden puts the human message in different places (error_description/error are often empty on a // bad login; the real text is in `message` or `errorModel.message`). Check them all. const errorModel = data.errorModel as Record | undefined; const candidates = [data.error_description, errorModel?.message, data.message, data.error]; const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed'; return { ok: false, status: res.status, error: desc }; } return { ok: true, data }; } export type PasswordGrantInput = { email: string; authHash: string; clientId: string; deviceIdentifier: string; deviceType: string; deviceName: string; }; /** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */ export function passwordGrant(p: PasswordGrantInput): Promise { const form = new URLSearchParams(); form.set('grant_type', 'password'); form.set('username', p.email); form.set('password', p.authHash); form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back form.set('client_id', p.clientId); form.set('deviceIdentifier', p.deviceIdentifier); form.set('deviceType', p.deviceType); form.set('deviceName', p.deviceName); return postConnectToken(form); } /** Renew the access token from the stored refresh token (no password needed). */ export function refreshGrant(refreshToken: string, clientId: string): Promise { const form = new URLSearchParams(); form.set('grant_type', 'refresh_token'); form.set('refresh_token', refreshToken); form.set('client_id', clientId); return postConnectToken(form); } // Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively. export function pick(data: Record, name: string): unknown { const lower = name.charAt(0).toLowerCase() + name.slice(1); const upper = name.charAt(0).toUpperCase() + name.slice(1); return data[name] ?? data[lower] ?? data[upper]; } /** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */ export function expiryFrom(data: Record, now: number): Date | null { const secs = data.expires_in; return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null; }