All files / src/auth jwt.ts

83.05% Statements 49/59
54.54% Branches 12/22
100% Functions 8/8
85.45% Lines 47/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 1645x 5x                         5x                   5x   41x                       41x 4x           37x             37x                 5x 7x       7x   7x               5x 7x 7x   7x   1x     6x 6x   6x 1x 3x 1x 1x               5x 2x           5x 5x       5x 5x 3x         2x             5x 3x           5x 5x           5x 4x 4x   4x         4x 4x     4x 3x 1x           4x    
import jwt from "jsonwebtoken";
import { createHash, randomBytes } from "crypto";
 
export interface JwtConfig {
    secret: string;
    accessExpiresIn?: string;
    refreshExpiresIn?: string;
}
 
export interface AccessTokenPayload {
    userId: string;
    roles: string[];
}
 
let jwtConfig: JwtConfig = {
    secret: "",
    accessExpiresIn: "1h",
    refreshExpiresIn: "30d"
};
 
/**
 * Configure JWT settings - call this during initialization.
 * Validates the secret strength to prevent deployment with default/weak secrets.
 */
export function configureJwt(config: JwtConfig): void {
    // Reject obviously weak/default secrets
    const weakSecrets = new Set([
        "secret",
        "jwt-secret",
        "jwt_secret",
        "your-secret",
        "your-super-secret-jwt-key-change-in-production",
        "change-me",
        "changeme",
        "password",
        "test"
    ]);
 
    if (!config.secret || config.secret.length < 32) {
        throw new Error(
            "JWT secret is too short. Must be at least 32 characters. " +
            "Generate one with: node -e \"console.log(require('crypto').randomBytes(48).toString('base64'))\""
        );
    }
 
    Iif (weakSecrets.has(config.secret.toLowerCase())) {
        throw new Error(
            "JWT secret is a known default/weak value. Please use a strong, randomly generated secret. " +
            "Generate one with: node -e \"console.log(require('crypto').randomBytes(48).toString('base64'))\""
        );
    }
 
    jwtConfig = {
        ...jwtConfig,
        ...config
    };
}
 
/**
 * Generate an access token (short-lived, 1 hour by default)
 */
export function generateAccessToken(userId: string, roles: string[]): string {
    Iif (!jwtConfig.secret) {
        throw new Error("JWT secret not configured. Call configureJwt() first.");
    }
 
    const payload: AccessTokenPayload = { userId, roles };
 
    return jwt.sign(payload, jwtConfig.secret, {
        expiresIn: jwtConfig.accessExpiresIn as jwt.SignOptions["expiresIn"]
    });
}
 
/**
 * Get the expiration time of an access token in milliseconds from now
 */
export function getAccessTokenExpiryMs(): number {
    const duration = jwtConfig.accessExpiresIn || "1h";
    const match = duration.match(/^(\d+)([dhms])$/);
 
    if (!match) {
        // Default to 1 hour
        return 60 * 60 * 1000;
    }
 
    const value = parseInt(match[1], 10);
    const unit = match[2];
 
    switch (unit) {
        case "d": return value * 24 * 60 * 60 * 1000;
        case "h": return value * 60 * 60 * 1000;
        case "m": return value * 60 * 1000;
        case "s": return value * 1000;
        default: return 60 * 60 * 1000;
    }
}
 
/**
 * Get the expiration timestamp for an access token
 */
export function getAccessTokenExpiry(): number {
    return Date.now() + getAccessTokenExpiryMs();
}
 
/**
 * Verify and decode an access token
 */
export function verifyAccessToken(token: string): AccessTokenPayload | null {
    Iif (!jwtConfig.secret) {
        throw new Error("JWT secret not configured. Call configureJwt() first.");
    }
 
    try {
        const decoded = jwt.verify(token, jwtConfig.secret) as jwt.JwtPayload & AccessTokenPayload;
        return {
            userId: decoded.userId,
            roles: decoded.roles
        };
    } catch (error) {
        return null;
    }
}
 
/**
 * Generate a random refresh token (long-lived, 30 days by default)
 */
export function generateRefreshToken(): string {
    return randomBytes(40).toString("hex");
}
 
/**
 * Hash a refresh token for database storage (don't store raw tokens)
 */
export function hashRefreshToken(token: string): string {
    return createHash("sha256").update(token).digest("hex");
}
 
/**
 * Calculate refresh token expiration date
 */
export function getRefreshTokenExpiry(): Date {
    const duration = jwtConfig.refreshExpiresIn || "30d";
    const match = duration.match(/^(\d+)([dhms])$/);
 
    Iif (!match) {
        // Default to 30 days
        return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
    }
 
    const value = parseInt(match[1], 10);
    const unit = match[2];
 
    let ms: number;
    switch (unit) {
        case "d": ms = value * 24 * 60 * 60 * 1000; break;
        case "h": ms = value * 60 * 60 * 1000; break;
        case "m": ms = value * 60 * 1000; break;
        case "s": ms = value * 1000; break;
        default: ms = 30 * 24 * 60 * 60 * 1000;
    }
 
    return new Date(Date.now() + ms);
}