e6b50fb4f1
Fix all build/type/lint errors (zod 3.25 compat wrapper, Svelte 5 fixes), write 115 unit tests across 10 test files, expand seed script with demo data, update Docker config with migration on startup.
118 lines
3.1 KiB
TypeScript
118 lines
3.1 KiB
TypeScript
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import { prisma } from '../prisma.js';
|
|
import { DEFAULTS } from '$lib/utils/constants.js';
|
|
import type { JwtPayload, TokenPair } from '$lib/types/auth.js';
|
|
|
|
const SALT_ROUNDS = 12;
|
|
|
|
function getJwtSecret(): string {
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!secret) {
|
|
throw new Error('JWT_SECRET environment variable is not set');
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function getJwtExpiry(): string {
|
|
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY;
|
|
}
|
|
|
|
function getRefreshTokenExpiryDays(): number {
|
|
const envValue = process.env.REFRESH_TOKEN_EXPIRY;
|
|
if (envValue) {
|
|
const days = parseInt(envValue.replace('d', ''), 10);
|
|
if (!isNaN(days)) return days;
|
|
}
|
|
return DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS;
|
|
}
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return bcrypt.hash(password, SALT_ROUNDS);
|
|
}
|
|
|
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
export function signAccessToken(payload: JwtPayload): string {
|
|
return jwt.sign(payload, getJwtSecret(), {
|
|
expiresIn: getJwtExpiry() as string & jwt.SignOptions['expiresIn']
|
|
});
|
|
}
|
|
|
|
export function verifyAccessToken(token: string): JwtPayload {
|
|
try {
|
|
const decoded = jwt.verify(token, getJwtSecret()) as JwtPayload & jwt.JwtPayload;
|
|
return {
|
|
userId: decoded.userId,
|
|
email: decoded.email,
|
|
role: decoded.role
|
|
};
|
|
} catch {
|
|
throw new Error('Invalid or expired access token');
|
|
}
|
|
}
|
|
|
|
export function generateRefreshToken(): string {
|
|
const bytes = new Uint8Array(48);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export function getRefreshTokenExpiry(): Date {
|
|
const days = getRefreshTokenExpiryDays();
|
|
const expiry = new Date();
|
|
expiry.setDate(expiry.getDate() + days);
|
|
return expiry;
|
|
}
|
|
|
|
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
|
|
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
refreshToken: hashedToken,
|
|
refreshTokenExpiresAt: getRefreshTokenExpiry()
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function validateRefreshToken(
|
|
userId: string,
|
|
refreshToken: string
|
|
): Promise<boolean> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { refreshToken: true, refreshTokenExpiresAt: true }
|
|
});
|
|
|
|
if (!user?.refreshToken || !user.refreshTokenExpiresAt) {
|
|
return false;
|
|
}
|
|
|
|
if (new Date() > user.refreshTokenExpiresAt) {
|
|
return false;
|
|
}
|
|
|
|
return bcrypt.compare(refreshToken, user.refreshToken);
|
|
}
|
|
|
|
export async function revokeRefreshToken(userId: string): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
refreshToken: null,
|
|
refreshTokenExpiresAt: null
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function rotateTokens(userId: string, email: string, role: string): Promise<TokenPair> {
|
|
const accessToken = signAccessToken({ userId, email, role });
|
|
const refreshToken = generateRefreshToken();
|
|
await saveRefreshToken(userId, refreshToken);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|