Ctrl K
ComunidadGuías…

utils/setupAuth.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { readInstance } from "./instanceStore";
import { setupState } from "./plexConfig";

/**
 * The gate in front of claiming a fresh instance. The setup code is printed
 * only to the server logs, so whoever can read them (the operator) can claim
 * the instance and a visitor who finds the URL first can't. It lives in memory
 * and changes on every restart.
 */

// Holds a Plex sign-in in progress: for claiming (only after the setup code)
// or for the owner signing back in
export const SETUP_COOKIE = "np_setup";
export const SETUP_COOKIE_MAX_AGE_S = 15 * 60;
// Proves the owner signed in with the claiming Plex account
export const OWNER_COOKIE = "np_owner";
export const OWNER_COOKIE_MAX_AGE_S = 7 * 24 * 60 * 60;

// Retiring the code after this many misses keeps guessing hopeless: each new
// code is 60 random bits that only the logs show
const MAX_FAILED_ATTEMPTS = 10;
// Reprints on /setup visits, so the code is still findable after logs scroll
const REPRINT_MIN_GAP_MS = 60_000;

// Crockford base32: no I, L, O or U to misread
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

interface SetupCodeState {
  code: string;
  failures: number;
  printedAt: number;
}

const globalForSetup = globalThis as typeof globalThis & { __setupCode?: SetupCodeState };

function generateCode(): string {
  const bytes = randomBytes(12);
  const chars = Array.from(bytes, (b) => ALPHABET[b & 31]);
  return [0, 4, 8].map((i) => chars.slice(i, i + 4).join("")).join("-");
}

function print(state: SetupCodeState): void {
  state.printedAt = Date.now();
  const lines = [
    "Now Playing setup code",
    "",
    state.code,
    "",
    "Open /setup and enter it to connect your server.",
  ];
  const width = Math.max(...lines.map((l) => l.length)) + 4;
  console.log(
    [
      "",
      `┌${"─".repeat(width)}┐`,
      ...lines.map((l) => `│  ${l.padEnd(width - 2)}│`),
      `└${"─".repeat(width)}┘`,
      "",
    ].join("\n")
  );
}

function current(): SetupCodeState {
  return (globalForSetup.__setupCode ??= { code: generateCode(), failures: 0, printedAt: 0 });
}

function retire(): void {
  globalForSetup.__setupCode = { code: generateCode(), failures: 0, printedAt: 0 };
}

/** Prints the code while the instance is unclaimed; at most once a minute unless `force`. */
export function announceSetupCode(force = false): void {
  if (setupState() !== "unclaimed") return;
  const state = current();
  if (force || Date.now() - state.printedAt >= REPRINT_MIN_GAP_MS) print(state);
}

function normalize(input: string): string {
  return input.toUpperCase().replace(/[^0-9A-Z]/g, "");
}

function safeEqual(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

export type CodeCheck = "ok" | "wrong" | "retired";

export function checkSetupCode(input: string): CodeCheck {
  const state = current();
  if (safeEqual(normalize(input), normalize(state.code))) return "ok";
  if (++state.failures < MAX_FAILED_ATTEMPTS) return "wrong";

  retire();
  console.warn(`${MAX_FAILED_ATTEMPTS} wrong setup codes: that code no longer works. New one:`);
  announceSetupCode(true);
  return "retired";
}

/** Called once the instance is claimed, so the code can't be used again. */
export function discardSetupCode(): void {
  globalForSetup.__setupCode = undefined;
}

// ── Setup cookie ─────────────────────────────────────────────────────────────
// "<session id>.<expiry>.<signature>": the id keys the in-progress sign-in, the
// expiry bounds it, and the signature uses the instance secret

function sign(payload: string): string {
  return createHmac("sha256", readInstance().secret).update(`setup:${payload}`).digest("hex");
}

export function newSetupCookie(): { id: string; value: string } {
  const id = randomBytes(16).toString("hex");
  const payload = `${id}.${Date.now() + SETUP_COOKIE_MAX_AGE_S * 1000}`;
  return { id, value: `${payload}.${sign(payload)}` };
}

/** The setup session id, if the cookie is genuine and unexpired. */
export function readSetupCookie(value: string | undefined): string | null {
  const [id, expiry, signature] = value?.split(".") ?? [];
  if (!id || !expiry || !signature) return null;
  if (!safeEqual(signature, sign(`${id}.${expiry}`))) return null;
  return Number(expiry) > Date.now() ? id : null;
}

// ── Owner cookie ─────────────────────────────────────────────────────────────
// "<expiry>.<signature>", signed over the owner's Plex uuid, so it stops
// working if the instance is reset or claimed by someone else

function signOwner(uuid: string, expiry: string): string {
  return createHmac("sha256", readInstance().secret).update(`owner:${uuid}:${expiry}`).digest("hex");
}

export function newOwnerCookie(uuid: string): string {
  const expiry = String(Date.now() + OWNER_COOKIE_MAX_AGE_S * 1000);
  return `${expiry}.${signOwner(uuid, expiry)}`;
}

export function isOwnerCookie(value: string | undefined): boolean {
  const owner = readInstance().owner;
  const [expiry, signature] = value?.split(".") ?? [];
  if (!owner || !expiry || !signature) return false;
  return safeEqual(signature, signOwner(owner.uuid, expiry)) && Number(expiry) > Date.now();
}

export function isOwnerUuid(uuid: string): boolean {
  const owner = readInstance().owner;
  return !!owner && safeEqual(uuid, owner.uuid);
}