255 lines
5.8 KiB
JavaScript
255 lines
5.8 KiB
JavaScript
/**
|
|
* Token Manager for JWT access tokens and refresh tokens
|
|
* Implements secure session management with token rotation
|
|
*/
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const crypto = require('crypto');
|
|
const { hashValue, verifyHash, generateToken } = require('./encryption');
|
|
|
|
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes in seconds
|
|
const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days in seconds
|
|
const REFRESH_TOKEN_BYTES = 64; // 128 character hex string
|
|
|
|
let jwtSecret = null;
|
|
|
|
/**
|
|
* Initialize token manager with JWT secret
|
|
* @param {string} secret - JWT signing secret
|
|
*/
|
|
function initTokenManager(secret) {
|
|
if (!secret || typeof secret !== 'string') {
|
|
throw new Error('JWT secret is required');
|
|
}
|
|
|
|
jwtSecret = secret;
|
|
console.log('✅ Token manager initialized');
|
|
}
|
|
|
|
/**
|
|
* Generate device fingerprint from request
|
|
* @param {Object} req - HTTP request object
|
|
* @returns {string} Device fingerprint (32 character hex)
|
|
*/
|
|
function generateDeviceFingerprint(req) {
|
|
const components = [
|
|
req.headers['user-agent'] || '',
|
|
req.headers['accept-language'] || '',
|
|
req.ip || req.connection?.remoteAddress || '',
|
|
req.headers['x-forwarded-for'] || ''
|
|
];
|
|
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(components.join('|'))
|
|
.digest('hex')
|
|
.substring(0, 32);
|
|
}
|
|
|
|
/**
|
|
* Generate JWT access token
|
|
* @param {Object} payload - Token payload (userId, email, role, plan)
|
|
* @param {Object} options - Token options
|
|
* @returns {string} JWT token
|
|
*/
|
|
function generateAccessToken(payload, options = {}) {
|
|
if (!jwtSecret) {
|
|
throw new Error('Token manager not initialized');
|
|
}
|
|
|
|
const jti = crypto.randomUUID();
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const tokenPayload = {
|
|
jti,
|
|
userId: payload.userId,
|
|
email: payload.email,
|
|
role: payload.role || 'user',
|
|
plan: payload.plan || 'hobby',
|
|
iat: now,
|
|
exp: now + (options.ttl || ACCESS_TOKEN_TTL)
|
|
};
|
|
|
|
return jwt.sign(tokenPayload, jwtSecret, {
|
|
algorithm: 'HS256'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Verify and decode JWT access token
|
|
* @param {string} token - JWT token to verify
|
|
* @returns {Object|null} Decoded token payload or null if invalid
|
|
*/
|
|
function verifyAccessToken(token) {
|
|
if (!jwtSecret) {
|
|
throw new Error('Token manager not initialized');
|
|
}
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, jwtSecret, {
|
|
algorithms: ['HS256']
|
|
});
|
|
|
|
return decoded;
|
|
} catch (error) {
|
|
if (error.name === 'TokenExpiredError') {
|
|
return { expired: true, error: 'Token expired' };
|
|
}
|
|
if (error.name === 'JsonWebTokenError') {
|
|
return { invalid: true, error: 'Invalid token' };
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate refresh token
|
|
* @returns {Object} Object with token and tokenHash
|
|
*/
|
|
function generateRefreshToken() {
|
|
const token = generateToken(REFRESH_TOKEN_BYTES);
|
|
const { hash, salt } = hashValue(token);
|
|
|
|
return {
|
|
token,
|
|
tokenHash: `${salt}:${hash}`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify refresh token against stored hash
|
|
* @param {string} token - Refresh token to verify
|
|
* @param {string} storedHash - Stored hash in format "salt:hash"
|
|
* @returns {boolean} True if token matches hash
|
|
*/
|
|
function verifyRefreshToken(token, storedHash) {
|
|
if (!token || !storedHash) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const [salt, hash] = storedHash.split(':');
|
|
if (!salt || !hash) {
|
|
return false;
|
|
}
|
|
|
|
return verifyHash(token, hash, salt);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract token from Authorization header or cookie
|
|
* @param {Object} req - HTTP request object
|
|
* @param {string} cookieName - Name of the cookie containing token
|
|
* @returns {string|null} Token or null
|
|
*/
|
|
function extractToken(req, cookieName = 'access_token') {
|
|
// Check Authorization header first (Bearer token)
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
return authHeader.substring(7);
|
|
}
|
|
|
|
// Check cookie
|
|
if (req.headers.cookie) {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
return cookies[cookieName] || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse cookie header
|
|
* @param {string} cookieHeader - Cookie header string
|
|
* @returns {Object} Parsed cookies
|
|
*/
|
|
function parseCookies(cookieHeader) {
|
|
const cookies = {};
|
|
|
|
if (!cookieHeader) {
|
|
return cookies;
|
|
}
|
|
|
|
cookieHeader.split(';').forEach(cookie => {
|
|
const [name, ...rest] = cookie.split('=');
|
|
if (name && rest.length > 0) {
|
|
cookies[name.trim()] = rest.join('=').trim();
|
|
}
|
|
});
|
|
|
|
return cookies;
|
|
}
|
|
|
|
/**
|
|
* Create secure cookie string
|
|
* @param {string} name - Cookie name
|
|
* @param {string} value - Cookie value
|
|
* @param {Object} options - Cookie options
|
|
* @returns {string} Set-Cookie header value
|
|
*/
|
|
function createSecureCookie(name, value, options = {}) {
|
|
const parts = [`${name}=${value}`];
|
|
|
|
if (options.maxAge) {
|
|
parts.push(`Max-Age=${options.maxAge}`);
|
|
}
|
|
|
|
if (options.path) {
|
|
parts.push(`Path=${options.path}`);
|
|
} else {
|
|
parts.push('Path=/');
|
|
}
|
|
|
|
if (options.httpOnly !== false) {
|
|
parts.push('HttpOnly');
|
|
}
|
|
|
|
if (options.secure) {
|
|
parts.push('Secure');
|
|
}
|
|
|
|
if (options.sameSite) {
|
|
parts.push(`SameSite=${options.sameSite}`);
|
|
} else {
|
|
parts.push('SameSite=Strict');
|
|
}
|
|
|
|
return parts.join('; ');
|
|
}
|
|
|
|
/**
|
|
* Get token TTL values
|
|
* @returns {Object} Object with accessTokenTTL and refreshTokenTTL
|
|
*/
|
|
function getTokenTTL() {
|
|
return {
|
|
accessTokenTTL: ACCESS_TOKEN_TTL,
|
|
refreshTokenTTL: REFRESH_TOKEN_TTL
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if token manager is initialized
|
|
* @returns {boolean}
|
|
*/
|
|
function isTokenManagerInitialized() {
|
|
return jwtSecret !== null;
|
|
}
|
|
|
|
module.exports = {
|
|
initTokenManager,
|
|
generateDeviceFingerprint,
|
|
generateAccessToken,
|
|
verifyAccessToken,
|
|
generateRefreshToken,
|
|
verifyRefreshToken,
|
|
extractToken,
|
|
createSecureCookie,
|
|
parseCookies,
|
|
getTokenTTL,
|
|
isTokenManagerInitialized
|
|
};
|