Working with latest DB

This commit is contained in:
Chandresh Kerkar 2025-12-14 20:30:30 +05:30
parent ea87d97364
commit 9e842c6774
7 changed files with 434 additions and 220 deletions

View File

@ -41,6 +41,7 @@
<div class="section">
<h2>3. Complete Profile (name + user_type)</h2>
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
<p>Uses the <code>access_token</code> from step 2</p>
<label>Name</label>
@ -53,21 +54,64 @@
<option value="service_provider">Service Provider</option>
</select>
<button id="updateProfileBtn">Update Profile</button>
<button id="updateProfileBtn" disabled>Update Profile (Verify OTP First)</button>
<pre id="updateProfileResult"></pre>
</div>
<div class="section">
<h2>4. Get User Details</h2>
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
<p>Authenticated GET request to fetch user profile with JWT token</p>
<p>Shows: phone number, name, profile type, location, last login, and all saved locations</p>
<button id="getUserDetailsBtn">Get User Details</button>
<button id="getUserDetailsBtn" disabled>Get User Details (Verify OTP First)</button>
<pre id="getUserDetailsResult"></pre>
</div>
<div class="section">
<h2>5. Add/Update Location</h2>
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
<p>Save your current location or a custom address</p>
<label>Location Type</label>
<select id="locationType">
<option value="home">Home</option>
<option value="farm">Farm</option>
<option value="office">Office</option>
<option value="temporary_gps">Temporary GPS</option>
<option value="other_saved">Other Saved</option>
</select>
<label>
<input type="checkbox" id="useCurrentLocation" /> Use Current GPS Location
</label>
<label>Country</label>
<input id="country" type="text" placeholder="India" />
<label>State</label>
<input id="state" type="text" placeholder="Maharashtra" />
<label>District</label>
<input id="district" type="text" placeholder="Mumbai" />
<label>City/Village</label>
<input id="cityVillage" type="text" placeholder="Mumbai" />
<label>Pincode</label>
<input id="pincode" type="text" placeholder="400001" />
<label>
<input type="checkbox" id="isPrimaryLocation" /> Set as Primary Location
</label>
<button id="saveLocationBtn" disabled>Save Location (Verify OTP First)</button>
<pre id="saveLocationResult"></pre>
</div>
<script>
let accessToken = null;
let refreshToken = null;
@ -165,6 +209,25 @@
refreshToken = data.refresh_token;
console.log('Access token set:', accessToken);
console.log('Refresh token set:', refreshToken);
// Enable buttons that require authentication
const getUserDetailsBtn = document.getElementById('getUserDetailsBtn');
const updateProfileBtn = document.getElementById('updateProfileBtn');
const saveLocationBtn = document.getElementById('saveLocationBtn');
if (getUserDetailsBtn) {
getUserDetailsBtn.disabled = false;
getUserDetailsBtn.textContent = 'Get User Details';
}
if (updateProfileBtn) {
updateProfileBtn.disabled = false;
updateProfileBtn.textContent = 'Update Profile';
}
if (saveLocationBtn) {
saveLocationBtn.disabled = false;
saveLocationBtn.textContent = 'Save Location';
}
alert('OTP verified successfully! needs_profile = ' + data.needs_profile);
} else {
alert('Error: ' + (data.error || data.message || 'OTP verification failed'));
@ -249,8 +312,43 @@
console.log('Response data:', data);
if (res.ok) {
out.textContent = JSON.stringify(data, null, 2);
// Format user details with new schema fields
const formattedData = {
id: data.id,
phone_number: data.phone_number,
name: data.name,
user_type: data.user_type, // System role: user/admin/moderator
roles: data.roles, // Marketplace roles array
active_role: data.active_role, // Active marketplace role
rating_average: data.rating_average,
rating_count: data.rating_count,
subscription_plan_id: data.subscription_plan_id,
subscription_expires_at: data.subscription_expires_at,
avatar_url: data.avatar_url,
language: data.language,
timezone: data.timezone,
country_code: data.country_code,
created_at: data.created_at,
last_login_at: data.last_login_at,
active_devices_count: data.active_devices_count,
primary_location: data.location,
all_locations: data.locations
};
out.textContent = JSON.stringify(formattedData, null, 2);
alert('User details fetched successfully!');
// Auto-fill location fields if primary location exists
if (data.location) {
document.getElementById('country').value = data.location.country || '';
document.getElementById('state').value = data.location.state || '';
document.getElementById('district').value = data.location.district || '';
document.getElementById('cityVillage').value = data.location.city_village || '';
document.getElementById('pincode').value = data.location.pincode || '';
if (data.location.location_type) {
document.getElementById('locationType').value = data.location.location_type;
}
}
} else {
out.textContent = `Error ${res.status}: ${JSON.stringify(data, null, 2)}`;
@ -268,6 +366,84 @@
}
}
async function saveLocation() {
const out = document.getElementById('saveLocationResult');
let lat = null;
let lng = null;
if (!accessToken) {
alert('No access_token. Verify OTP first.');
out.textContent = 'Error: No access token. Please verify OTP first.';
return;
}
// Get current location if checkbox is checked
if (document.getElementById('useCurrentLocation').checked) {
try {
out.textContent = 'Getting current location...';
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
lat = position.coords.latitude;
lng = position.coords.longitude;
console.log('Current location:', lat, lng);
} catch (err) {
alert('Error getting location: ' + err.message);
out.textContent = 'Error getting GPS location: ' + err.message;
return;
}
}
const locationData = {
location_type: document.getElementById('locationType').value,
country: document.getElementById('country').value.trim() || null,
state: document.getElementById('state').value.trim() || null,
district: document.getElementById('district').value.trim() || null,
city_village: document.getElementById('cityVillage').value.trim() || null,
pincode: document.getElementById('pincode').value.trim() || null,
is_saved_address: true,
selected_location: document.getElementById('isPrimaryLocation').checked,
};
if (lat !== null && lng !== null) {
locationData.lat = lat;
locationData.lng = lng;
}
out.textContent = 'Saving location...';
console.log('Saving location:', locationData);
try {
// Note: You'll need to add a POST /users/me/locations endpoint
// For now, we'll use a generic approach
const res = await fetch('/users/me/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + accessToken,
},
body: JSON.stringify(locationData)
});
console.log('Response status:', res.status);
const data = await res.json();
console.log('Response data:', data);
out.textContent = JSON.stringify(data, null, 2);
if (res.ok) {
alert('Location saved successfully!');
// Refresh user details to show new location
getUserDetails();
} else {
alert('Error: ' + (data.error || data.message || 'Failed to save location'));
}
} catch (err) {
console.error('Save location error:', err);
out.textContent = 'Error: ' + err.message;
alert('Error: ' + err.message);
}
}
// Attach event listeners to buttons (replaces inline onclick handlers)
// Since script is at end of body, DOM is already loaded
(function() {
@ -275,6 +451,7 @@
const verifyOtpBtn = document.getElementById('verifyOtpBtn');
const updateProfileBtn = document.getElementById('updateProfileBtn');
const getUserDetailsBtn = document.getElementById('getUserDetailsBtn');
const saveLocationBtn = document.getElementById('saveLocationBtn');
if (requestOtpBtn) {
requestOtpBtn.addEventListener('click', requestOtp);
@ -292,6 +469,10 @@
getUserDetailsBtn.addEventListener('click', getUserDetails);
console.log('Get User Details button listener attached');
}
if (saveLocationBtn) {
saveLocationBtn.addEventListener('click', saveLocation);
console.log('Save Location button listener attached');
}
console.log('Test page loaded. All event listeners attached.');
})();

View File

@ -43,12 +43,12 @@ async function requireRecentOtpOrReauth(req, res, next) {
const userId = req.user.id;
// Check if token has high_assurance claim
// Check if token has high_assurance claim (set after OTP verification)
if (req.user.high_assurance === true) {
return next(); // Token already has high assurance
}
// Check for recent OTP verification in audit logs
// Check for recent OTP verification in audit logs (within the time window)
const recentOtpResult = await db.query(
`SELECT created_at
FROM auth_audit
@ -66,6 +66,24 @@ async function requireRecentOtpOrReauth(req, res, next) {
return next();
}
// Also check if this is initial profile completion (user has no name)
// Allow initial profile setup without step-up auth
try {
const userCheck = await db.query(
`SELECT name FROM users WHERE id = $1 AND deleted = FALSE`,
[userId]
);
if (userCheck.rows.length > 0 && (!userCheck.rows[0].name || userCheck.rows[0].name.trim() === '')) {
// User has no name, this is initial profile completion - allow it
console.log(`[STEP_UP_AUTH] Allowing initial profile completion for user ${userId}`);
return next();
}
} catch (err) {
// If check fails, continue with step-up requirement
console.error('Error checking user profile:', err);
}
// No recent OTP or high assurance token
// Log the attempt
await logStepUpAuthRequired(

View File

@ -225,21 +225,41 @@ function validateLogoutBody(req, res, next) {
// === VALIDATION: USER ROUTES ===
/**
* Validate user_type enum value
* Allowed values: 'seller', 'buyer', 'service_provider' (from user_type_enum in database)
* Validate marketplace role (for active_role field)
* Maps old values to new schema:
* - 'seller' or 'buyer' -> 'seller_buyer'
* - 'service_provider' -> 'service_provider'
*
* Database: active_role uses listing_role_enum: ('seller_buyer', 'service_provider')
*/
function validateUserType(userType) {
if (userType === undefined || userType === null) {
function validateMarketplaceRole(role) {
if (role === undefined || role === null) {
return { valid: true }; // Optional field
}
if (typeof userType !== 'string') {
if (typeof role !== 'string') {
return { valid: false, error: 'user_type must be a string' };
}
const allowedValues = ['seller', 'buyer', 'service_provider'];
if (!allowedValues.includes(userType)) {
return { valid: false, error: `user_type must be one of: ${allowedValues.join(', ')}` };
// Normalize to lowercase for comparison
const normalized = role.toLowerCase();
// Map old values to new schema
const roleMapping = {
'seller': 'seller_buyer',
'buyer': 'seller_buyer',
'service_provider': 'service_provider'
};
if (roleMapping[normalized]) {
return { valid: true, mappedValue: roleMapping[normalized] };
}
return { valid: true };
// Also accept new values directly
const allowedValues = ['seller_buyer', 'service_provider'];
if (allowedValues.includes(normalized)) {
return { valid: true, mappedValue: normalized };
}
return { valid: false, error: `user_type must be one of: seller, buyer, service_provider, seller_buyer` };
}
/**
@ -261,7 +281,8 @@ function validateName(name) {
/**
* Middleware: Validate PUT /users/me request body
* Validates: name (optional string, trimmed, max 100), user_type (optional enum)
* Validates: name (required string, trimmed, max 100), user_type (marketplace role: seller/buyer/service_provider)
* Maps user_type to active_role field in database
* Rejects: unknown extra fields, invalid types, overly long strings
*/
function validateUpdateProfileBody(req, res, next) {
@ -283,22 +304,30 @@ function validateUpdateProfileBody(req, res, next) {
});
}
// Validate name (optional)
// Validate name (required)
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const nameCheck = validateName(name);
if (!nameCheck.valid) {
return res.status(400).json({ error: nameCheck.error });
}
// Validate user_type (optional)
const userTypeCheck = validateUserType(user_type);
if (!userTypeCheck.valid) {
return res.status(400).json({ error: userTypeCheck.error });
// Validate user_type (required) - maps to active_role in database
if (!user_type) {
return res.status(400).json({ error: 'user_type is required' });
}
const roleCheck = validateMarketplaceRole(user_type);
if (!roleCheck.valid) {
return res.status(400).json({ error: roleCheck.error });
}
// Trim name if provided
// Trim name and store mapped role value
if (name && typeof name === 'string') {
req.body.name = name.trim();
}
// Store the mapped marketplace role value for the route handler
req.body.active_role = roleCheck.mappedValue;
next();
}

View File

@ -398,6 +398,8 @@ router.post(
device_model = EXCLUDED.device_model,
os_version = EXCLUDED.os_version,
app_version = EXCLUDED.app_version,
language_code = EXCLUDED.language_code,
timezone = EXCLUDED.timezone,
is_active = true
`,
[
@ -427,10 +429,10 @@ router.post(
});
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
// Log authentication event with risk level
// Log OTP verification event with risk level (needed for step-up authentication)
await logAuthEvent({
userId: user.id,
action: 'login',
action: 'otp_verify',
status: 'success',
riskLevel: riskScore.isSuspicious
? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS)
@ -562,7 +564,24 @@ router.post(
}
}
const newAccess = signAccessToken(user);
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// Preserve high_assurance status if there's been a recent OTP verification
// This ensures step-up authentication remains valid after token refresh
const RECENT_OTP_WINDOW_MINUTES = parseInt(process.env.STEP_UP_OTP_WINDOW_MINUTES || '5', 10);
const recentOtpCheck = await db.query(
`SELECT created_at
FROM auth_audit
WHERE user_id = $1
AND action = 'otp_verify'
AND status = 'success'
AND created_at > NOW() - INTERVAL '${RECENT_OTP_WINDOW_MINUTES} minutes'
ORDER BY created_at DESC
LIMIT 1`,
[userId]
);
const hasRecentOtp = recentOtpCheck.rows.length > 0;
const newAccess = signAccessToken(user, { highAssurance: hasRecentOtp });
const newRefresh = await rotateRefreshToken({
tokenRow,
userAgent: req.headers['user-agent'],

View File

@ -28,11 +28,13 @@ const router = express.Router();
// Rate limited: Read operation (100 requests per 15 minutes per user)
router.get('/me', auth, userRateLimitRead, async (req, res) => {
try {
// Get user basic information
// Get user basic information (with new schema fields)
const { rows } = await db.query(
`SELECT id, phone_number, name, role, user_type, created_at, last_login_at, avatar_url, language, timezone
`SELECT id, phone_number, name, user_type, roles, active_role,
rating_average, rating_count, subscription_plan_id, subscription_expires_at,
created_at, last_login_at, avatar_url, language, timezone, country_code
FROM users
WHERE id = $1`,
WHERE id = $1 AND deleted = FALSE`,
[req.user.id]
);
@ -55,7 +57,7 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
);
const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10);
// Get user's saved locations (addresses)
// Get user's saved locations (addresses) - with new schema fields
const locationsResult = await db.query(
`SELECT
id,
@ -68,12 +70,13 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
lng,
location_type,
is_saved_address,
selected_location,
source_type,
source_confidence,
created_at,
updated_at
FROM locations
WHERE user_id = $1 AND is_saved_address = true
WHERE user_id = $1 AND is_saved_address = true AND deleted = FALSE
ORDER BY updated_at DESC`,
[req.user.id]
);
@ -91,6 +94,7 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
} : null,
location_type: loc.location_type,
is_saved_address: loc.is_saved_address,
selected_location: loc.selected_location || false,
source_type: loc.source_type,
source_confidence: loc.source_confidence,
created_at: loc.created_at,
@ -104,11 +108,17 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
id: user.id,
phone_number: user.phone_number,
name: user.name,
role: user.role,
user_type: user.user_type,
user_type: user.user_type, // System role: 'user', 'admin', 'moderator'
roles: user.roles || ['seller_buyer'], // Marketplace roles array
active_role: user.active_role || 'seller_buyer', // Active marketplace role
rating_average: user.rating_average || 0,
rating_count: user.rating_count || 0,
subscription_plan_id: user.subscription_plan_id,
subscription_expires_at: user.subscription_expires_at,
avatar_url: user.avatar_url,
language: user.language,
timezone: user.timezone,
country_code: user.country_code || '+91',
created_at: user.created_at,
last_login_at: user.last_login_at,
active_devices_count: activeDevicesCount,
@ -132,21 +142,39 @@ router.put(
validateUpdateProfileBody,
async (req, res) => {
try {
const { name, user_type } = req.body;
const { name, active_role } = req.body;
if (!name || !user_type) {
return res.status(400).json({ error: 'name and user_type are required' });
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
// Build update query - active_role comes from validation middleware
const updateFields = ['name = $1'];
const updateValues = [name];
let paramIndex = 2;
if (active_role) {
// active_role is already validated and mapped by validation middleware
updateFields.push(`active_role = $${paramIndex}`);
updateValues.push(active_role);
paramIndex++;
// Also update roles array to include this role if not already present
updateFields.push(`roles = CASE WHEN $${paramIndex}::listing_role_enum = ANY(roles) THEN roles ELSE array_append(roles, $${paramIndex}::listing_role_enum) END`);
updateValues.push(active_role);
paramIndex++;
}
updateValues.push(req.user.id);
const { rows } = await db.query(
`
UPDATE users
SET name = $1,
user_type = $2
WHERE id = $3
RETURNING id, phone_number, name, role, user_type
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex} AND deleted = FALSE
RETURNING id, phone_number, name, user_type, roles, active_role, rating_average, rating_count
`,
[name, user_type, req.user.id]
updateValues
);
if (rows.length === 0) {
@ -379,4 +407,96 @@ router.post(
}
);
// POST /users/me/locations - Save a new location
// Rate limited: Write operation (20 requests per 15 minutes per user)
router.post(
'/me/locations',
auth,
userRateLimitWrite,
async (req, res) => {
try {
const {
location_type,
country,
state,
district,
city_village,
pincode,
lat,
lng,
is_saved_address = true,
selected_location = false,
source_type = 'manual',
source_confidence = 'medium',
} = req.body;
// Validate required fields
if (!location_type) {
return res.status(400).json({ error: 'location_type is required' });
}
// Insert location
const { rows } = await db.query(
`INSERT INTO locations (
user_id, location_type, country, state, district, city_village, pincode,
lat, lng, is_saved_address, selected_location, source_type, source_confidence
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, location_type, country, state, district, city_village, pincode,
lat, lng, is_saved_address, selected_location, source_type, source_confidence,
created_at, updated_at`,
[
req.user.id,
location_type,
country || null,
state || null,
district || null,
city_village || null,
pincode || null,
lat || null,
lng || null,
is_saved_address,
selected_location,
source_type,
source_confidence,
]
);
// If this is set as primary location, unset others
if (selected_location) {
await db.query(
`UPDATE locations
SET selected_location = false
WHERE user_id = $1 AND id != $2`,
[req.user.id, rows[0].id]
);
}
const location = rows[0];
return res.json({
id: location.id,
location_type: location.location_type,
country: location.country,
state: location.state,
district: location.district,
city_village: location.city_village,
pincode: location.pincode,
coordinates: location.lat && location.lng ? {
latitude: parseFloat(location.lat),
longitude: parseFloat(location.lng)
} : null,
is_saved_address: location.is_saved_address,
selected_location: location.selected_location,
source_type: location.source_type,
source_confidence: location.source_confidence,
created_at: location.created_at,
updated_at: location.updated_at,
});
} catch (err) {
console.error('Save location error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
}
);
module.exports = router;

View File

@ -217,21 +217,23 @@ async function getPreviousAuthInfo(userId, deviceId) {
};
}
// Fallback: check refresh_tokens for this device
// Fallback: check user_devices for this device
if (deviceId) {
const tokenResult = await db.query(
`SELECT ip_address, user_agent
FROM refresh_tokens
WHERE user_id = $1 AND device_id = $2
ORDER BY created_at DESC
const deviceResult = await db.query(
`SELECT lat, lng, last_seen_at
FROM user_devices
WHERE user_id = $1 AND device_identifier = $2
ORDER BY last_seen_at DESC
LIMIT 1`,
[userId, deviceId]
);
if (tokenResult.rows.length > 0) {
// Device info available but IP/user_agent not tracked in user_devices table
// Return deviceId as previousDeviceId
if (deviceResult.rows.length > 0) {
return {
previousIp: tokenResult.rows[0].ip_address,
previousUserAgent: tokenResult.rows[0].user_agent,
previousIp: null, // Not tracked in new schema
previousUserAgent: null, // Not tracked in new schema
previousDeviceId: deviceId,
};
}
@ -259,10 +261,3 @@ module.exports = {
getPreviousAuthInfo,
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH,
};

View File

@ -4,60 +4,28 @@ const bcrypt = require('bcrypt');
const { randomUUID } = require('crypto');
const db = require('../db');
const config = require('../config');
// === SECURITY HARDENING: JWT KEY ROTATION ===
const {
getActiveKey,
getKeySecret,
getAllKeys,
getIssuer,
getAudience,
validateTokenClaims,
} = require('./jwtKeys');
const ACCESS_SECRET = config.jwtAccessSecret;
const REFRESH_SECRET = config.jwtRefreshSecret;
const ACCESS_TTL = config.jwtAccessTtl;
const REFRESH_TTL = config.jwtRefreshTtl;
const REFRESH_MAX_IDLE_MS = config.refreshMaxIdleMinutes * 60 * 1000;
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Get active key for signing
const { keyId: ACTIVE_KEY_ID, secret: ACTIVE_SECRET } = getActiveKey();
// For refresh tokens, use a separate key if available, otherwise use active key
const REFRESH_KEY_ID = process.env.JWT_REFRESH_KEY_ID || ACTIVE_KEY_ID;
const REFRESH_SECRET = getKeySecret(REFRESH_KEY_ID) || ACTIVE_SECRET;
function signAccessToken(user, options = {}) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in token header and add standard claims
const payload = {
sub: user.id,
phone_number: user.phone_number,
role: user.role,
user_type: user.user_type || null,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Standard JWT claims
iss: getIssuer(),
aud: getAudience(),
iat: Math.floor(Date.now() / 1000),
// High assurance flag (set after OTP verification for sensitive actions)
high_assurance: options.highAssurance || false,
// === SECURITY HARDENING: GLOBAL LOGOUT ===
// Token version for immediate invalidation on logout-all-devices
token_version: user.token_version || 1,
};
return jwt.sign(
payload,
ACTIVE_SECRET,
{
expiresIn: ACCESS_TTL,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in header for key rotation support
header: {
kid: ACTIVE_KEY_ID,
alg: 'HS256',
},
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
// Include high_assurance flag if requested (for step-up authentication)
if (options.highAssurance === true) {
payload.high_assurance = true;
}
);
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_TTL });
}
async function issueRefreshToken({
@ -67,31 +35,15 @@ async function issueRefreshToken({
ip,
rotatedFromId = null,
}) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
const tokenId = randomUUID();
const payload = {
const token = jwt.sign(
{
sub: userId,
device_id: deviceId,
jti: tokenId,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Standard JWT claims
iss: getIssuer(),
aud: getAudience(),
iat: Math.floor(Date.now() / 1000),
};
const token = jwt.sign(
payload,
REFRESH_SECRET,
{
expiresIn: REFRESH_TTL,
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Include key ID in header
header: {
kid: REFRESH_KEY_ID,
alg: 'HS256',
},
}
REFRESH_SECRET,
{ expiresIn: REFRESH_TTL }
);
const decoded = jwt.decode(token);
const expiresAt = new Date(decoded.exp * 1000);
@ -150,62 +102,13 @@ async function storeRefreshToken({
}
async function verifyRefreshToken(rawToken) {
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Decode token to get key ID from header
let decoded;
let payload;
try {
decoded = jwt.decode(rawToken, { complete: true });
if (!decoded || !decoded.header) {
return null;
}
payload = jwt.verify(rawToken, REFRESH_SECRET);
} catch (err) {
return null;
}
// Get secret for the key ID (if present) or try all keys
const keyId = decoded.header.kid;
let payload = null;
let verified = false;
if (keyId) {
const secret = getKeySecret(keyId);
if (secret) {
try {
payload = jwt.verify(rawToken, secret);
verified = true;
} catch (err) {
// Key ID specified but verification failed
return null;
}
}
}
// If key ID not found or not specified, try all keys (for rotation support)
if (!verified) {
const allKeys = getAllKeys();
for (const [kid, keySecret] of Object.entries(allKeys)) {
try {
payload = jwt.verify(rawToken, keySecret);
verified = true;
break;
} catch (err) {
// Try next key
continue;
}
}
}
if (!verified || !payload) {
return null;
}
// === SECURITY HARDENING: JWT KEY ROTATION ===
// Validate JWT claims (iss, aud, exp, iat, nbf)
const claimsValidation = validateTokenClaims(payload);
if (!claimsValidation.valid) {
return null;
}
const { sub: userId, device_id: deviceId, jti: tokenId } = payload;
if (!tokenId) {
return null;
@ -306,61 +209,10 @@ async function handleReuse(tokenRow) {
await revokeDeviceTokens(tokenRow.user_id, tokenRow.device_id);
}
/**
* === SECURITY HARDENING: GLOBAL LOGOUT ===
* Revoke all refresh tokens and invalidate all access tokens for a user
* by incrementing their token_version. This is used for "logout from all devices".
*
* @param {string} userId - User ID
* @returns {Promise<Object>} Object with revoked tokens count and new token version
*/
async function revokeAllUserTokens(userId) {
// Revoke all refresh tokens for the user
const revokeResult = await db.query(
`
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE user_id = $1 AND revoked_at IS NULL
RETURNING id
`,
[userId]
);
const revokedTokensCount = revokeResult.rows.length;
// Mark all devices as inactive
await db.query(
`
UPDATE user_devices
SET is_active = false
WHERE user_id = $1 AND is_active = true
`,
[userId]
);
// Increment token_version to invalidate all existing access tokens
const versionResult = await db.query(
`
UPDATE users
SET token_version = COALESCE(token_version, 1) + 1
WHERE id = $1
RETURNING token_version
`,
[userId]
);
const newTokenVersion = versionResult.rows[0]?.token_version || 1;
return {
revokedTokensCount,
newTokenVersion,
};
}
module.exports = {
signAccessToken,
verifyRefreshToken,
issueRefreshToken,
rotateRefreshToken,
revokeRefreshToken,
revokeAllUserTokens,
};