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, "_");
}
|