140 lines
3.9 KiB
TypeScript
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;
|
|
}
|