/** * Bearer-token CRUD. * * Extends the minimal token support in auth.ts (legacy single-token) with * named multi-token management. Each token entry has: * - id: short random identifier * - token: the bearer secret (base64url, 32 bytes) * - name: human label (e.g. "Jay's iPhone") * - createdAt: ISO timestamp * - deviceToken?: APNs device token (set when device pairs in Phase 2) * - environment?: "production" | "sandbox" * * Stored as JSON in $state_dir/auth/tokens.json (mode 0o600). * * Owner: T-1.3 */ import { randomBytes, timingSafeEqual } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; // --------------------------------------------------------------------------- // In-memory cache for sync validation (WS upgrade can't await) // --------------------------------------------------------------------------- const _tokenCache = new Set(); function cacheToken(token: string): void { _tokenCache.add(token); } /** Sync bearer validation — checks in-memory cache populated at runtime. */ export function validateBearerSync(bearer: string): boolean { return _tokenCache.has(bearer); } /** Warm the cache from disk on startup. */ export async function warmTokenCache(stateDir?: string): Promise { const entries = await loadTokens(stateDir); for (const e of entries) _tokenCache.add(e.token); } // --------------------------------------------------------------------------- export interface TokenEntry { id: string; token: string; name: string; createdAt: string; deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+ environment?: "production" | "sandbox"; } function tokensPath(stateDir?: string): string { const base = stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"); return path.join(base, "auth", "tokens.json"); } async function loadTokens(stateDir?: string): Promise { try { const raw = await fs.readFile(tokensPath(stateDir), "utf8"); return JSON.parse(raw) as TokenEntry[]; } catch { return []; } } async function saveTokens( entries: TokenEntry[], stateDir?: string, ): Promise { const fp = tokensPath(stateDir); await fs.mkdir(path.dirname(fp), { recursive: true }); await fs.writeFile(fp, JSON.stringify(entries, null, 2), { encoding: "utf8", mode: 0o600, }); } /** Create a new named bearer token. Returns the new entry (token visible once). */ export async function createToken( name: string, stateDir?: string, ): Promise { const entries = await loadTokens(stateDir); const entry: TokenEntry = { id: randomBytes(6).toString("base64url"), token: randomBytes(32).toString("base64url"), name, createdAt: new Date().toISOString(), }; entries.push(entry); await saveTokens(entries, stateDir); cacheToken(entry.token); return entry; } /** List all tokens (token field is included — protect at the API layer). */ export async function listTokens(stateDir?: string): Promise { return loadTokens(stateDir); } /** Revoke a token by id. Returns true if found and removed. */ export async function revokeToken( id: string, stateDir?: string, ): Promise { const entries = await loadTokens(stateDir); const before = entries.length; const filtered = entries.filter((e) => e.id !== id); if (filtered.length === before) return false; await saveTokens(filtered, stateDir); return true; } /** Rename a token. Returns true if found. */ export async function renameToken( id: string, newName: string, stateDir?: string, ): Promise { const entries = await loadTokens(stateDir); const entry = entries.find((e) => e.id === id); if (!entry) return false; entry.name = newName; await saveTokens(entries, stateDir); return true; } /** Update device token (called during Phase-2 pairing). */ export async function setDeviceToken( id: string, deviceToken: string, environment: "production" | "sandbox", stateDir?: string, ): Promise { const entries = await loadTokens(stateDir); const entry = entries.find((e) => e.id === id); if (!entry) return false; entry.deviceToken = deviceToken; entry.environment = environment; await saveTokens(entries, stateDir); return true; } /** * Validate a bearer token string against the store. * Returns the matching entry or null. */ export async function validateBearer( bearer: string, stateDir?: string, ): Promise { const entries = await loadTokens(stateDir); const b = Buffer.from(bearer); for (const entry of entries) { const a = Buffer.from(entry.token); if (a.length === b.length && timingSafeEqual(a, b)) return entry; } return null; }