'invalid_refresh_token'], 400); } try { $jwtKey = AppConfig::required('JWT_SIGNING_KEY'); if (strlen($jwtKey) < 32) { throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes'); } $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); $digest = hash('sha256', $refreshToken); $query = $db->prepare('SELECT s.session_uuid, s.family_uuid, s.user_id, s.device_uuid, s.replaced_by, s.revoked_at, s.expires_at, u.uuid, u.phone_e164, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_token_digest = ? FOR UPDATE'); $query->bind_param('s', $digest); $query->execute(); $session = $query->get_result()->fetch_assoc(); $query->close(); if (!$session) { $connection->rollback(); api_json(['error' => 'invalid_refresh_token'], 401); } if ($session['replaced_by'] !== null) { $revoke = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE family_uuid = ?'); $revoke->bind_param('s', $session['family_uuid']); $revoke->execute(); $revoke->close(); $connection->commit(); api_json(['error' => 'refresh_token_reuse_detected'], 401); } if ($session['revoked_at'] !== null || $session['expires_at'] <= gmdate('Y-m-d H:i:s') || !(bool) $session['is_active']) { $connection->rollback(); api_json(['error' => 'session_expired'], 401); } $newSessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)); $newRefresh = bin2hex(random_bytes(48)); $newDigest = hash('sha256', $newRefresh); $refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30))); $insert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)'); $insert->bind_param('ssissi', $newSessionId, $session['family_uuid'], $session['user_id'], $session['device_uuid'], $newDigest, $refreshDays); $insert->execute(); $insert->close(); $replace = $db->prepare('UPDATE auth_sessions SET last_used_at = UTC_TIMESTAMP(), replaced_by = ? WHERE session_uuid = ? AND replaced_by IS NULL'); $replace->bind_param('ss', $newSessionId, $session['session_uuid']); $replace->execute(); $replace->close(); $connection->commit(); $ttl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900))); api_json([ 'user' => ['id' => (int) $session['user_id'], 'uuid' => $session['uuid'], 'phone_e164' => $session['phone_e164'], 'account_role' => $session['account_role']], 'access_token' => JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl), 'token_type' => 'Bearer', 'expires_in_seconds' => $ttl, 'refresh_token' => $newRefresh, 'refresh_expires_in_days' => $refreshDays, ]); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('Session refresh failed: ' . $exception->getMessage()); api_json(['error' => 'service_unavailable'], 503); }