prepare(" SELECT u.*, d.device_fingerprint, d.refresh_expires_at FROM user_devices d JOIN users u ON u.id = d.user_id WHERE d.refresh_token_hash = ? AND d.device_fingerprint = ? AND d.is_trusted = 1 AND u.is_active = 1 LIMIT 1 "); $stmt->execute([$refreshTokenHash, $deviceId]); $user = $stmt->fetch(); if ($user) { $isMobile = true; if (!empty($user['refresh_expires_at']) && strtotime($user['refresh_expires_at']) < time()) { json_error('انتهت صلاحية الجلسة. يرجى تسجيل الدخول من جديد.', 401); } } } // 2. Web: single-column lookup. if (!$user) { $stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? AND is_active = 1 LIMIT 1"); $stmt->execute([$refreshTokenHash]); $user = $stmt->fetch(); } if (!$user) { json_error('Invalid refresh token', 401); } // 3. Generate a new access token. $secret = env('JWT_SECRET'); if (!$secret || strlen($secret) < 32) { error_log('FATAL: JWT_SECRET is missing or too short in .env'); json_error('Server configuration error', 500); } $payload = [ 'user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], 'device_id' => $isMobile ? $deviceId : null, 'source' => $isMobile ? 'mobile' : 'web', 'exp' => time() + ($isMobile ? (30 * 24 * 3600) : (15 * 60)), ]; $token = JWT::encode($payload, $secret); // 4. Rotate the refresh token so a leaked one cannot be replayed indefinitely. $newRefreshToken = bin2hex(random_bytes(32)); $newHash = hash('sha256', $newRefreshToken); if ($isMobile) { $expiresAt = date('Y-m-d H:i:s', time() + (60 * 24 * 3600)); $db->prepare(" UPDATE user_devices SET refresh_token_hash = ?, refresh_expires_at = ?, last_seen_at = NOW() WHERE user_id = ? AND device_fingerprint = ? ")->execute([$newHash, $expiresAt, $user['id'], $deviceId]); } else { $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?") ->execute([$newHash, $user['id']]); setcookie('refresh_token', $newRefreshToken, [ 'expires' => time() + (7 * 24 * 60 * 60), 'path' => '/api/v1/auth/refresh', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict', ]); } json_success([ 'access_token' => $token, // Web keeps receiving it via the HttpOnly cookie only. 'refresh_token' => $isMobile ? $newRefreshToken : null, ]);