| 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.", |
| ); |
| } |
| |
| 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; |
| 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 { |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| export function randomShareToken(): string { |
| return crypto |
| .randomBytes(32) |
| .toString("base64") |
| .replace(/=+$/g, "") |
| .replace(/\+/g, "-") |
| .replace(/\//g, "_"); |
| } |
|
|