vault, extracted from the platform into its own repository
The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.
What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.
Two things changed rather than moved:
• VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
writing it. A plugin cannot ask the owner to edit a file and restart a
process, so the env var could not survive the move. Server-wide, decided
with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
cache rather than per request, since it sits in front of every proxied byte.
• Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
handlers. That is the security property behind the distress and panic
buttons, so it could not simply be dropped — the platform gained a small
account-lifecycle hook instead, and the severity mapping moved here where it
belongs. Core now names no table and no column of this plugin.
Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:
• The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
Officer's account gate; top-level unauthenticated prefixes are not something
a manifest can declare. Its own comments already called it TEMPORARY,
pending a first-party browser extension that authenticates to Officer.
• The notifications websocket. No plugin owns a socket on this platform yet.
SignalR falls back to HTTP long-polling on its own, so clients still sync.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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<string, unknown> } | { ok: false; status: number; error: string };
|
||||
|
||||
async function postConnectToken(form: URLSearchParams): Promise<ConnectResult> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<ConnectResult> {
|
||||
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<ConnectResult> {
|
||||
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<string, unknown>, 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<string, unknown>, now: number): Date | null {
|
||||
const secs = data.expires_in;
|
||||
return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing
|
||||
// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are
|
||||
// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs.
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
]);
|
||||
|
||||
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */
|
||||
export function stripHopByHop(src: Headers): Headers {
|
||||
const out = new Headers();
|
||||
src.forEach((value, key) => {
|
||||
const k = key.toLowerCase();
|
||||
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
|
||||
out.set(key, value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi;
|
||||
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { originMiddleware, userMiddleware } from '@@/_middlewares';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { onAccountLifecycle } from '@@/account-lifecycle';
|
||||
import { manifest } from '../manifest';
|
||||
import { getVaultServerUrl } from './sidecar-server';
|
||||
import { stripHopByHop, redactPath } from './proxy-util';
|
||||
import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker';
|
||||
import { getValidAccessToken, refreshAccess } from './token-store';
|
||||
import {
|
||||
getVaultConfig,
|
||||
setVaultConfig,
|
||||
getVaultTokens,
|
||||
setVaultTokens,
|
||||
getVaultUnlockKey,
|
||||
setVaultUnlockKey,
|
||||
clearVaultTokens,
|
||||
clearVaultUnlockKey,
|
||||
} from '../db/queries';
|
||||
|
||||
// Platform side of Officer Vault. The device holds NO Vaultwarden token; a valid platform session
|
||||
// authorizes vault access. This router:
|
||||
// • gates everything on an owner platform session (userMiddleware, no bodyParser → streaming),
|
||||
// • serves the instance config, the native broker and the unlock-key endpoints,
|
||||
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
|
||||
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
|
||||
//
|
||||
// ── The prefix is DERIVED, not written ──
|
||||
//
|
||||
// It was the literal '/api/vault' before the extraction, which is right only by coincidence:
|
||||
// `mountPrefix` returns `/vault` for a first-party publisher, and the catch-all below slices exactly
|
||||
// `PREFIX.length` characters off the path to build the sidecar's. Published by anyone else this mounts at
|
||||
// `/api/p/<publisher>/vault` and a literal would forward `/alice/vault/identity/...` to a sidecar
|
||||
// expecting `/identity/...`. Same reasoning, and the same fix, as `plugins/music/api/router.ts`.
|
||||
//
|
||||
// `appName` is a literal for the reason recorded there too: the platform imports this module and reads
|
||||
// `router`, so there is nowhere for it to inject the plugin's own directory name.
|
||||
const PREFIX = `/api${mountPrefix({ appName: 'vault', manifest })}`;
|
||||
|
||||
export const router = createRouter();
|
||||
|
||||
/**
|
||||
* Wipe this account's vault state when its session ends. Registered as a side effect of the router being
|
||||
* imported, which the platform does once when the plugin's routes are mounted — so the subscription's
|
||||
* lifetime is "installed and enabled", with no lifecycle of its own to get wrong.
|
||||
*
|
||||
* The three levels are the platform's, and the severity mapping is ours:
|
||||
*
|
||||
* signout drop the brokered session, KEEP the protector key — logging out should not cost a
|
||||
* master-password re-setup, and the token is unusable without a platform session
|
||||
* anyway (the one that was just blacklisted).
|
||||
* distress / panic drop both. This is the "someone got in" path, so the convenience material goes
|
||||
* too and the next unlock needs the master password. Exactly what the platform-side
|
||||
* code did before the extraction; the mapping moved, the behaviour did not.
|
||||
*
|
||||
* Failures are swallowed per call, deliberately — `emitAccountLifecycle` already logs a rejection, and a
|
||||
* vault whose tables are missing (uninstalled, never pushed) must not turn signing out into a 500.
|
||||
*/
|
||||
onAccountLifecycle(async (userId, event) => {
|
||||
await clearVaultTokens(userId).catch(() => {});
|
||||
if (event !== 'signout') await clearVaultUnlockKey(userId).catch(() => {});
|
||||
});
|
||||
|
||||
// Owner-only, and this is the ONE gate that matters now.
|
||||
//
|
||||
// Before the extraction there were two: the platform's own backstop refused non-owner tokens on
|
||||
// `/api/vault`, and this repeated it. A plugin's permission is grantable per role by design — the rule is
|
||||
// uniform and has no `ownerOnly` — so a member could hold `vault` at read or write. That is fine for the
|
||||
// permission (it decides whether the ROUTE is reachable) and not fine for the vault (one Vaultwarden
|
||||
// instance, one brokered token set, all of it the owner's). So the router keeps its own answer rather
|
||||
// than inheriting one, which is exactly what the plugin contract says a plugin's queries are for.
|
||||
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only');
|
||||
return next();
|
||||
};
|
||||
|
||||
router.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check
|
||||
router.use(userMiddleware); // valid platform session (Authorization: Bearer <platform JWT> or ?token=)
|
||||
router.use(ownerGate);
|
||||
|
||||
// ── Instance configuration (registered before the catch-all) ──
|
||||
|
||||
/**
|
||||
* GET /api/vault/config → `{ url, enabled, updatedAt, configured }`
|
||||
*
|
||||
* `configured` is derived rather than stored: a client wants to know "can this do anything yet", and
|
||||
* that is a URL being present, not a row existing.
|
||||
*/
|
||||
router.get('/config', async (ctx) => {
|
||||
const config = await getVaultConfig();
|
||||
return ctx.json({ ...config, configured: !!config.url });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/vault/config `{ url?: string | null, enabled?: boolean }`
|
||||
*
|
||||
* Refuses anything that is not an absolute http(s) URL. Not a security boundary — this is owner-only and
|
||||
* the value reaches `fetch`, never a shell — but a typo saved here fails later as "upstream unreachable",
|
||||
* which sends the reader looking at their Vaultwarden instead of at what they typed.
|
||||
*/
|
||||
router.put('/config', async (ctx) => {
|
||||
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
const patch: { url?: string | null; enabled?: boolean } = {};
|
||||
|
||||
if (body.url !== undefined) {
|
||||
if (body.url === null || body.url === '') {
|
||||
patch.url = null;
|
||||
} else if (typeof body.url !== 'string') {
|
||||
throw errors.BAD_REQUEST('url must be a string, or null to clear it');
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(body.url.trim());
|
||||
} catch {
|
||||
throw errors.BAD_REQUEST('url must be an absolute URL, e.g. https://vault.example.com');
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw errors.BAD_REQUEST('url must be http:// or https://');
|
||||
}
|
||||
patch.url = body.url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
if (typeof body.enabled !== 'boolean') throw errors.BAD_REQUEST('enabled must be a boolean');
|
||||
patch.enabled = body.enabled;
|
||||
}
|
||||
|
||||
if (patch.url === undefined && patch.enabled === undefined) {
|
||||
throw errors.BAD_REQUEST('nothing to update — send url and/or enabled');
|
||||
}
|
||||
|
||||
const config = await setVaultConfig(patch);
|
||||
return ctx.json({ ...config, configured: !!config.url });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/vault/config/health — does the configured Vaultwarden actually answer?
|
||||
*
|
||||
* Forwards to the sidecar's own `/_health`, which probes Vaultwarden's `/alive`. Kept separate from
|
||||
* `GET /config` so that reading the setting never blocks on a network round trip to a box that may be
|
||||
* off — the panel renders the URL immediately and fills the status in behind it.
|
||||
*/
|
||||
async function probeHealth(): Promise<Response> {
|
||||
const base = getVaultServerUrl();
|
||||
if (!base) return Response.json({ ok: false, error: 'vault sidecar unavailable' }, { status: 503 });
|
||||
try {
|
||||
const r = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) });
|
||||
const body = await r.json().catch(() => ({ ok: r.ok }));
|
||||
return Response.json(body, { status: r.status });
|
||||
} catch {
|
||||
return Response.json({ ok: false, error: 'vault sidecar unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/config/health', () => probeHealth());
|
||||
|
||||
// The same answer at its original path: the phone app already probes `/_health`, and renaming it would be
|
||||
// a client change for no reason. `/config/health` is the name the settings panel reads better.
|
||||
router.get('/_health', () => probeHealth());
|
||||
|
||||
// ── Native endpoints ──
|
||||
|
||||
// Token broker — exchange the client-derived auth hash for a Vaultwarden token set (stored server-side),
|
||||
// and return the encrypted user-key material the client needs to unlock on-device.
|
||||
router.post('/session/login', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
||||
const email = str(b.email);
|
||||
const authHash = str(b.authHash);
|
||||
if (!email || !authHash) return ctx.json({ ok: false, error: 'email and authHash required' }, 400);
|
||||
|
||||
const clientId = str(b.clientId) || 'mobile';
|
||||
const input: PasswordGrantInput = {
|
||||
email,
|
||||
authHash,
|
||||
clientId,
|
||||
deviceIdentifier: str(b.deviceIdentifier),
|
||||
deviceType: b.deviceType != null ? String(b.deviceType) : '',
|
||||
deviceName: str(b.deviceName),
|
||||
};
|
||||
const result = await passwordGrant(input);
|
||||
if (!result.ok) return Response.json({ ok: false, error: result.error }, { status: result.status });
|
||||
|
||||
const d = result.data;
|
||||
const access = pick(d, 'access_token');
|
||||
const refresh = pick(d, 'refresh_token');
|
||||
if (typeof access !== 'string' || typeof refresh !== 'string') {
|
||||
return ctx.json({ ok: false, error: 'vault returned no token' }, 502);
|
||||
}
|
||||
await setVaultTokens(user.id, {
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: expiryFrom(d, Date.now()),
|
||||
deviceIdentifier: input.deviceIdentifier || null,
|
||||
clientId,
|
||||
});
|
||||
|
||||
// Everything except the transport tokens (which the platform holds) is crypto material the SDK unlocks
|
||||
// on-device — ciphertext to us. Vaultwarden returns BOTH classic (Key/PrivateKey/Kdf*) and v2
|
||||
// (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys); pass the whole native response through
|
||||
// under `connectToken` so the SDK gets whatever it uses, alongside the spec's named aliases.
|
||||
const connectToken: Record<string, unknown> = { ...d };
|
||||
for (const k of ['access_token', 'refresh_token', 'token_type', 'expires_in']) delete connectToken[k];
|
||||
return ctx.json({
|
||||
ok: true,
|
||||
protectedUserKey: pick(d, 'Key') ?? null,
|
||||
privateKey: pick(d, 'PrivateKey') ?? null,
|
||||
kdf: {
|
||||
kdf: pick(d, 'Kdf') ?? null,
|
||||
kdfIterations: pick(d, 'KdfIterations') ?? null,
|
||||
kdfMemory: pick(d, 'KdfMemory') ?? null,
|
||||
kdfParallelism: pick(d, 'KdfParallelism') ?? null,
|
||||
},
|
||||
connectToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Officer-app unlock path: store / release the platform-held protector key (owner session only).
|
||||
router.put('/unlock-key', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (typeof b.wrappedKey !== 'string' || !b.wrappedKey) return ctx.json({ error: 'wrappedKey required' }, 400);
|
||||
await setVaultUnlockKey(user.id, b.wrappedKey);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/unlock-key', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const wrappedKey = await getVaultUnlockKey(user.id);
|
||||
if (!wrappedKey) return ctx.json({ error: 'no unlock key stored' }, 404);
|
||||
return ctx.json({ wrappedKey });
|
||||
});
|
||||
|
||||
// ── Token-injecting proxy → sidecar → Vaultwarden ──
|
||||
router.all('/*', async (ctx) => {
|
||||
const base = getVaultServerUrl();
|
||||
if (!base) return ctx.text('Vault sidecar not available', 503);
|
||||
|
||||
const user = ctx.get('user');
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${base}${subpath}${url.search}`;
|
||||
const method = ctx.req.method;
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
const started = Date.now();
|
||||
|
||||
// Inject a valid Vaultwarden token (proactively refreshed near expiry). No token yet → forward without
|
||||
// one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401.
|
||||
const accessToken = await getValidAccessToken(user.id);
|
||||
|
||||
const headers = stripHopByHop(ctx.req.raw.headers);
|
||||
headers.delete('authorization'); // drop the platform JWT
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
|
||||
if (hasBody) {
|
||||
init.body = ctx.req.raw.body;
|
||||
init.duplex = 'half';
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, init);
|
||||
} catch {
|
||||
console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
|
||||
return ctx.text('Vault sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
// A 401 on a replayable (no-body) request → token likely just expired/revoked: refresh once and retry.
|
||||
if (upstream.status === 401 && !hasBody) {
|
||||
const ts = await getVaultTokens(user.id);
|
||||
const fresh = ts ? await refreshAccess(user.id, ts) : null;
|
||||
if (fresh) {
|
||||
headers.set('authorization', `Bearer ${fresh}`);
|
||||
upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`);
|
||||
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it
|
||||
// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward
|
||||
// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('vault:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[vault] sidecar proxy registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
|
||||
export function getVaultServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
|
||||
/** Same, as a WebSocket base for the notifications hub. */
|
||||
export function getVaultServerWsUrl(): string | null {
|
||||
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from '../db/queries';
|
||||
import { refreshGrant, pick, expiryFrom } from './broker';
|
||||
|
||||
// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it
|
||||
// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the
|
||||
// notifications-WS proxy so both inject a fresh token. The device never sees any of this.
|
||||
|
||||
const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401
|
||||
|
||||
/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */
|
||||
export async function refreshAccess(userId: number, ts: VaultTokenSet): Promise<string | null> {
|
||||
const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile');
|
||||
if (!r.ok) return null;
|
||||
const access = pick(r.data, 'access_token');
|
||||
const refresh = pick(r.data, 'refresh_token');
|
||||
if (typeof access !== 'string') return null;
|
||||
await updateVaultAccess(
|
||||
userId,
|
||||
access,
|
||||
typeof refresh === 'string' ? refresh : ts.refreshToken,
|
||||
expiryFrom(r.data, Date.now()),
|
||||
);
|
||||
return access;
|
||||
}
|
||||
|
||||
/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */
|
||||
export async function getValidAccessToken(userId: number): Promise<string | null> {
|
||||
const ts = await getVaultTokens(userId);
|
||||
if (!ts) return null;
|
||||
if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) {
|
||||
return (await refreshAccess(userId, ts)) ?? ts.accessToken;
|
||||
}
|
||||
return ts.accessToken;
|
||||
}
|
||||
Reference in New Issue
Block a user