File size: 2,916 Bytes
5871090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import crypto from "node:crypto";
import bcrypt from "bcrypt";
import jwt, { type SignOptions } from "jsonwebtoken";

function resolveSecret(): string {
  const fromEnv =
    process.env["JWT_SECRET"] ||
    process.env["AUTH_SECRET"] ||
    process.env["SESSION_SECRET"];
  if (fromEnv && fromEnv.length >= 16) return fromEnv;
  if (process.env["NODE_ENV"] === "production") {
    throw new Error(
      "JWT_SECRET (or AUTH_SECRET/SESSION_SECRET) must be set to a strong value (>=16 chars) in production.",
    );
  }
  // Dev-only: generate a random secret per process.
  const generated = crypto.randomBytes(32).toString("hex");
  process.env["AUTH_SECRET"] = generated;
  return generated;
}

const SECRET = resolveSecret();
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days
const BCRYPT_ROUNDS = 12;

export interface TokenPayload {
  sub: string;
  iat: number;
  exp: number;
}

export function signToken(userId: string): string {
  const opts: SignOptions = {
    expiresIn: TOKEN_TTL_SECONDS,
    algorithm: "HS256",
  };
  return jwt.sign({ sub: userId }, SECRET, opts);
}

export function tokenTtlSeconds(): number {
  return TOKEN_TTL_SECONDS;
}

export function verifyToken(token: string): TokenPayload | null {
  try {
    const decoded = jwt.verify(token, SECRET, {
      algorithms: ["HS256"],
    }) as jwt.JwtPayload;
    if (typeof decoded.sub !== "string" || typeof decoded.exp !== "number") {
      return null;
    }
    return {
      sub: decoded.sub,
      iat: typeof decoded.iat === "number" ? decoded.iat : 0,
      exp: decoded.exp,
    };
  } catch {
    return null;
  }
}

export function hashPassword(password: string): string {
  return bcrypt.hashSync(password, BCRYPT_ROUNDS);
}

export function verifyPassword(password: string, stored: string): boolean {
  // Backwards-compat: tolerate the old scrypt format ("hex:hex") so existing
  // dev rows keep working until rehashed on next login.
  if (stored.includes(":") && !stored.startsWith("$2")) {
    const parts = stored.split(":");
    if (parts.length !== 2) return false;
    const [saltHex, hashHex] = parts;
    if (!saltHex || !hashHex) return false;
    try {
      const salt = Buffer.from(saltHex, "hex");
      const expected = Buffer.from(hashHex, "hex");
      const actual = crypto.scryptSync(password, salt, 64);
      if (actual.length !== expected.length) return false;
      return crypto.timingSafeEqual(actual, expected);
    } catch {
      return false;
    }
  }
  try {
    return bcrypt.compareSync(password, stored);
  } catch {
    return false;
  }
}

/**
 * Cryptographically random token for public share links and similar
 * unguessable identifiers. 32 bytes -> 256 bits of entropy, base64url-encoded.
 */
export function randomShareToken(): string {
  return crypto
    .randomBytes(32)
    .toString("base64")
    .replace(/=+$/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}