pi-remote-control/extensions/remote-control/auth/tokens.ts

140 lines
3.9 KiB
TypeScript

/**
* 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";
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<TokenEntry[]> {
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<void> {
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<TokenEntry> {
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);
return entry;
}
/** List all tokens (token field is included — protect at the API layer). */
export async function listTokens(stateDir?: string): Promise<TokenEntry[]> {
return loadTokens(stateDir);
}
/** Revoke a token by id. Returns true if found and removed. */
export async function revokeToken(
id: string,
stateDir?: string,
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<TokenEntry | null> {
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;
}